From edd34a00a731a20ae3a035473b4a86ce45043d53 Mon Sep 17 00:00:00 2001 From: lukas Date: Thu, 1 Oct 2026 11:31:23 +0200 Subject: [PATCH] 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 --- CONTEXT.md | 4 + drizzle/0008_absurd_the_anarchist.sql | 9 + drizzle/meta/0008_snapshot.json | 942 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + .../orders/OrderStatusView.module.scss | 20 + src/components/orders/OrderStatusView.tsx | 105 +- src/db/schema.ts | 11 + src/db/seed.ts | 6 + src/domain/ordering.ts | 165 ++- src/server/orders.ts | 14 +- tests/ordering.test.ts | 91 +- 11 files changed, 1316 insertions(+), 58 deletions(-) create mode 100644 drizzle/0008_absurd_the_anarchist.sql create mode 100644 drizzle/meta/0008_snapshot.json diff --git a/CONTEXT.md b/CONTEXT.md index 4809491..bec8486 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/drizzle/0008_absurd_the_anarchist.sql b/drizzle/0008_absurd_the_anarchist.sql new file mode 100644 index 0000000..4d9bfc3 --- /dev/null +++ b/drizzle/0008_absurd_the_anarchist.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..2952012 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2bde7e8..5812ce8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss index f74a5ef..82f4ff4 100644 --- a/src/components/orders/OrderStatusView.module.scss +++ b/src/components/orders/OrderStatusView.module.scss @@ -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; diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index 55a81f0..b236c91 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -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 }) => ( ) +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 ( + + ) +} + 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 = ({ )}
+ + {item.pickupBox && ( + + void runAction(item.id, () => togglePickupBox(item)) + } + exit={pickupButtonExit} + > + + {item.pickupBox.isOpen + ? `Close box ${item.pickupBox.number}` + : `Open box ${item.pickupBox.number}`} + + )} + - )} ().notNull().default('placed'), // Only set once status is 'cancelled'. 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), createdAt: timestamp('created_at').defaultNow().notNull(), }) diff --git a/src/db/seed.ts b/src/db/seed.ts index 84dad61..c55d431 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -45,6 +45,11 @@ export const seedGhostKitchen = async (db: NodePgDatabase) => { .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) => { assembly: assemblyTag, }, machines: { prep1, oven1, grill1, assembly1 }, + pickupBoxes, menuItems: { pizza, burger }, menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions }, allergens, diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index c66f609..00ffa49 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -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 + // 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, } }) diff --git a/src/server/orders.ts b/src/server/orders.ts index c47f629..ce4011c 100644 --- a/src/server/orders.ts +++ b/src/server/orders.ts @@ -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 diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts index 94b6e40..625d996 100644 --- a/tests/ordering.test.ts +++ b/tests/ordering.test.ts @@ -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, @@ -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') })