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') })