From 6fc315a5f223891471b2415b76d36d00d267aabe Mon Sep 17 00:00:00 2001 From: lukas Date: Thu, 1 Oct 2026 23:26:37 +0200 Subject: [PATCH] Discard items left in their pickup box past a 15-minute window An item's pickup window starts when it goes into its box. Once it runs out, the item is discarded without a refund and the box is freed; a box that's open at that moment is left alone. With no scheduler yet, reads, box actions and the kitchen simulation check for expired items first. The order card shows a live countdown under "Ready for pickup" and a "Not picked up in time" banner once discarded. Also bounces the lock icon and animates the pickup button's label changes. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 4 +- drizzle/0010_lush_black_bolt.sql | 1 + drizzle/meta/0010_snapshot.json | 954 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + .../orders/OrderStatusView.module.scss | 1 + src/components/orders/OrderStatusView.tsx | 121 ++- .../orders/OrdersListView.module.scss | 3 +- src/db/schema.ts | 4 + src/domain/ordering.ts | 103 +- src/lib/format.ts | 7 + src/routes/help.tsx | 1 + tests/format.test.ts | 19 +- tests/ordering.test.ts | 61 ++ 13 files changed, 1248 insertions(+), 38 deletions(-) create mode 100644 drizzle/0010_lush_black_bolt.sql create mode 100644 drizzle/meta/0010_snapshot.json 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 ( - { strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" + style={{ originY: 1 }} + initial={false} + animate={ + open + ? { + y: [0, -4, 1, -1.5, 0], + scaleX: [1, 0.94, 1.06, 0.98, 1], + scaleY: [1, 1.1, 0.92, 1.03, 1], + } + : { + y: [0, 2, -3.5, 1, -1, 0], + scaleX: [1, 1.14, 0.94, 1.04, 0.99, 1], + scaleY: [1, 0.8, 1.1, 0.96, 1.02, 1], + } + } + transition={ + reduceMotion ? { duration: 0 } : { duration: 0.65, ease: 'easeOut' } + } > - + ) } @@ -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) })