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 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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.
|
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
|
||||||
|
|
||||||
**Order**:
|
**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**:
|
**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.
|
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.
|
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**:
|
**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)
|
_Avoid_: Counter, shelf (the box is a lockable compartment the customer operates themselves)
|
||||||
|
|
||||||
**Operator**:
|
**Operator**:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "order_items" ADD COLUMN "placed_in_box_at" timestamp;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,13 @@
|
|||||||
"when": 1790888552762,
|
"when": 1790888552762,
|
||||||
"tag": "0009_damp_ender_wiggin",
|
"tag": "0009_damp_ender_wiggin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790889640651,
|
||||||
|
"tag": "0010_lush_black_bolt",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -373,6 +373,7 @@
|
|||||||
.pickupButton {
|
.pickupButton {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
border-color: $color-text;
|
border-color: $color-text;
|
||||||
background: $color-text;
|
background: $color-text;
|
||||||
color: $color-background;
|
color: $color-background;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
|
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
|
||||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
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 { HoldButton } from '#/components/ui/HoldButton.tsx'
|
||||||
import type { OrderStatus } from '#/db/schema.ts'
|
import type { OrderStatus } from '#/db/schema.ts'
|
||||||
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
||||||
import { useBasket } from '#/lib/basket.tsx'
|
import { useBasket } from '#/lib/basket.tsx'
|
||||||
|
import { formatCountdown } from '#/lib/format.ts'
|
||||||
import {
|
import {
|
||||||
advanceSimulatedKitchen,
|
advanceSimulatedKitchen,
|
||||||
cancelOrderItem,
|
cancelOrderItem,
|
||||||
@@ -23,19 +24,32 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|||||||
const isSettled = (item: OrderItemStatusView) =>
|
const isSettled = (item: OrderItemStatusView) =>
|
||||||
item.status === 'ready' ||
|
item.status === 'ready' ||
|
||||||
item.status === 'picked_up' ||
|
item.status === 'picked_up' ||
|
||||||
item.status === 'cancelled'
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'discarded'
|
||||||
|
|
||||||
// `animationKey` groups variants that should read as one continuous banner:
|
// `animationKey` groups variants that should read as one continuous banner:
|
||||||
// going from ready to picked up swaps the label without replaying the check.
|
// 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') {
|
if (item.status === 'ready') {
|
||||||
return {
|
return {
|
||||||
animationKey: 'done',
|
animationKey: 'done',
|
||||||
variant: 'ready',
|
variant: 'ready',
|
||||||
label: 'Ready for pickup',
|
label: 'Ready for pickup',
|
||||||
detail: item.pickupBox
|
detail: !item.pickupBox
|
||||||
? `Pickup box ${item.pickupBox.number}`
|
? 'Waiting for a free pickup box'
|
||||||
: '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
|
} as const
|
||||||
}
|
}
|
||||||
if (item.status === 'picked_up') {
|
if (item.status === 'picked_up') {
|
||||||
@@ -68,7 +82,7 @@ const getItemBanner = (item: OrderItemStatusView) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bannerKey = (item: OrderItemStatusView) => {
|
const bannerKey = (item: OrderItemStatusView) => {
|
||||||
const banner = getItemBanner(item)
|
const banner = getItemBanner(item, null)
|
||||||
return banner ? `${item.id}:${banner.animationKey}` : null
|
return banner ? `${item.id}:${banner.animationKey}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,20 +130,28 @@ const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
const LOCK_DURATION_SECONDS = 0.25
|
|
||||||
const CLOSED_BOX_VISIBLE_MS = 5 * 60 * 1000
|
const CLOSED_BOX_VISIBLE_MS = 5 * 60 * 1000
|
||||||
// Same commands in both so the shackle morphs: unlocked, the left leg
|
// 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.
|
// 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 LOCKED_SHACKLE = 'M7 11V7a5 5 0 0 1 10 0v4'
|
||||||
const UNLOCKED_SHACKLE = 'M7 11V4a5 5 0 0 1 10 0v1'
|
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 LockIcon = ({ open }: { open: boolean }) => {
|
||||||
const reduceMotion = useReducedMotion()
|
const reduceMotion = useReducedMotion()
|
||||||
const transition = reduceMotion
|
|
||||||
? { duration: 0 }
|
|
||||||
: { duration: LOCK_DURATION_SECONDS, ease: 'easeOut' as const }
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<motion.svg
|
||||||
className={`${styles.actionIcon} ${styles.lockIcon}`}
|
className={`${styles.actionIcon} ${styles.lockIcon}`}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -138,14 +160,34 @@ const LockIcon = ({ open }: { open: boolean }) => {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
aria-hidden="true"
|
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' }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" />
|
<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" />
|
||||||
<motion.path
|
<motion.path
|
||||||
initial={false}
|
initial={false}
|
||||||
animate={{ d: open ? UNLOCKED_SHACKLE : LOCKED_SHACKLE }}
|
animate={{ d: open ? UNLOCKED_SHACKLE : LOCKED_SHACKLE }}
|
||||||
transition={transition}
|
transition={
|
||||||
|
reduceMotion ? { duration: 0 } : { duration: 0.4, ease: SHACKLE_EASE }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</motion.svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +236,22 @@ export const OrderStatusView = ({
|
|||||||
const [initialBannerKeys] = useState(
|
const [initialBannerKeys] = useState(
|
||||||
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
|
() => new Set(status.items.map(bannerKey).filter((key) => key !== null)),
|
||||||
)
|
)
|
||||||
// A closed box's button stays on screen, disabled and locked, for a while
|
// Ticks every second while a pickup countdown runs; otherwise only wakes
|
||||||
// after pickup; re-render when the next one is due to animate out.
|
// when a closed box's button (kept for a while after pickup) is due to go.
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
useEffect(() => setHydrated(true), [])
|
||||||
|
const hasPickupCountdown = status.items.some(
|
||||||
|
(item) => item.pickupDeadline !== null,
|
||||||
|
)
|
||||||
useEffect(() => {
|
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
|
const hideAt = status.items
|
||||||
.flatMap((item) =>
|
.flatMap((item) =>
|
||||||
item.pickedUpAt
|
item.pickedUpAt
|
||||||
@@ -211,7 +265,22 @@ export const OrderStatusView = ({
|
|||||||
Math.min(...hideAt) - Date.now(),
|
Math.min(...hideAt) - Date.now(),
|
||||||
)
|
)
|
||||||
return () => clearTimeout(timeout)
|
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<number>())
|
||||||
|
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<ReadonlySet<number>>(
|
const [expandedItemIds, setExpandedItemIds] = useState<ReadonlySet<number>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
)
|
)
|
||||||
@@ -343,7 +412,7 @@ export const OrderStatusView = ({
|
|||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
<ul className={styles.items}>
|
<ul className={styles.items}>
|
||||||
{status.items.map((item) => {
|
{status.items.map((item) => {
|
||||||
const banner = getItemBanner(item)
|
const banner = getItemBanner(item, hydrated ? now : null)
|
||||||
const boxClosed = item.status !== 'ready'
|
const boxClosed = item.status !== 'ready'
|
||||||
const pickupBox =
|
const pickupBox =
|
||||||
item.status === 'ready' ||
|
item.status === 'ready' ||
|
||||||
@@ -489,11 +558,17 @@ export const OrderStatusView = ({
|
|||||||
exit={pickupButtonExit}
|
exit={pickupButtonExit}
|
||||||
>
|
>
|
||||||
<LockIcon open={pickupBox.isOpen} />
|
<LockIcon open={pickupBox.isOpen} />
|
||||||
{boxClosed
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
? `Box ${pickupBox.number} closed`
|
<motion.span
|
||||||
: pickupBox.isOpen
|
key={pickupButtonLabel(pickupBox, boxClosed)}
|
||||||
? `Hold to close box ${pickupBox.number}`
|
initial={{ opacity: 0, y: 6 }}
|
||||||
: `Hold to open box ${pickupBox.number}`}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: reduceMotion ? 0 : 0.18 }}
|
||||||
|
>
|
||||||
|
{pickupButtonLabel(pickupBox, boxClosed)}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
</HoldButton>
|
</HoldButton>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -138,7 +138,8 @@
|
|||||||
color: $color-success;
|
color: $color-success;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-progress='cancelled'] {
|
&[data-progress='cancelled'],
|
||||||
|
&[data-progress='discarded'] {
|
||||||
color: $color-danger;
|
color: $color-danger;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ export const orderItemStatusValues = [
|
|||||||
'ready',
|
'ready',
|
||||||
'picked_up',
|
'picked_up',
|
||||||
'cancelled',
|
'cancelled',
|
||||||
|
// Left uncollected in its Pickup Box past the pickup window; not refunded.
|
||||||
|
'discarded',
|
||||||
] as const
|
] as const
|
||||||
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
|
export type OrderItemStatus = (typeof orderItemStatusValues)[number]
|
||||||
|
|
||||||
@@ -162,6 +164,8 @@ export const orderItems = pgTable('order_items', {
|
|||||||
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
|
cancelledBy: text('cancelled_by').$type<OrderItemCancelledBy>(),
|
||||||
// Set once the item is ready and a Pickup Box is free; kept after pickup.
|
// Set once the item is ready and a Pickup Box is free; kept after pickup.
|
||||||
pickupBoxId: integer('pickup_box_id').references(() => pickupBoxes.id),
|
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'),
|
pickedUpAt: timestamp('picked_up_at'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
})
|
})
|
||||||
|
|||||||
+92
-11
@@ -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 type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
import * as schema from '#/db/schema.ts'
|
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 {
|
export class PickupBoxNotOpenError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Open the pickup box before closing it')
|
super('Open the pickup box before closing it')
|
||||||
@@ -491,7 +498,10 @@ const updateOrderCompleteIfEverySiblingIsDone = async (
|
|||||||
.where(eq(schema.orderItems.orderId, orderId))
|
.where(eq(schema.orderItems.orderId, orderId))
|
||||||
|
|
||||||
const orderComplete = siblingItems.every(
|
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) {
|
if (orderComplete) {
|
||||||
@@ -528,7 +538,11 @@ export const cancelOrderItem = async (
|
|||||||
if (orderItem.status === 'cancelled') {
|
if (orderItem.status === 'cancelled') {
|
||||||
throw new OrderItemNotCancellableError('it has already been 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')
|
throw new OrderItemNotCancellableError('it is already done cooking')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,6 +756,8 @@ export const runKitchenSimulationTick = async (
|
|||||||
policy: KitchenSimulationPolicy,
|
policy: KitchenSimulationPolicy,
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
) => {
|
) => {
|
||||||
|
await discardUncollectedItems(db, now)
|
||||||
|
|
||||||
const inProgressSteps = await db
|
const inProgressSteps = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.preparationStepInstances)
|
.from(schema.preparationStepInstances)
|
||||||
@@ -804,11 +820,55 @@ const assignFreePickupBoxes = async (db: DbOrTx) => {
|
|||||||
for (const [index, item] of waitingItems.entries()) {
|
for (const [index, item] of waitingItems.entries()) {
|
||||||
await db
|
await db
|
||||||
.update(schema.orderItems)
|
.update(schema.orderItems)
|
||||||
.set({ pickupBoxId: freeBoxes[index].id })
|
.set({ pickupBoxId: freeBoxes[index].id, placedInBoxAt: new Date() })
|
||||||
.where(eq(schema.orderItems.id, item.id))
|
.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 (
|
const getPickupBoxForReadyItem = async (
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
orderItemId: number,
|
orderItemId: number,
|
||||||
@@ -831,6 +891,7 @@ const getPickupBoxForReadyItem = async (
|
|||||||
if (!order) throw new Error('Order not found')
|
if (!order) throw new Error('Order not found')
|
||||||
assertOrderAccess(order, access)
|
assertOrderAccess(order, access)
|
||||||
|
|
||||||
|
if (orderItem.status === 'discarded') throw new PickupWindowExpiredError()
|
||||||
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
|
if (orderItem.status !== 'ready') throw new OrderItemNotReadyError()
|
||||||
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
|
if (orderItem.pickupBoxId === null) throw new NoPickupBoxYetError()
|
||||||
|
|
||||||
@@ -852,6 +913,7 @@ export const openPickupBox = async (
|
|||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
await discardUncollectedItems(tx)
|
||||||
const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
|
const { box } = await getPickupBoxForReadyItem(tx, orderItemId, access)
|
||||||
await tx
|
await tx
|
||||||
.update(schema.pickupBoxes)
|
.update(schema.pickupBoxes)
|
||||||
@@ -867,6 +929,7 @@ export const closePickupBox = async (
|
|||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
await discardUncollectedItems(tx)
|
||||||
const { orderItem, box } = await getPickupBoxForReadyItem(
|
const { orderItem, box } = await getPickupBoxForReadyItem(
|
||||||
tx,
|
tx,
|
||||||
orderItemId,
|
orderItemId,
|
||||||
@@ -908,10 +971,13 @@ export type OrderItemStatusView = {
|
|||||||
// Menu Item recreates its Options under new ids, so the ids at order time
|
// 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.
|
// can't be reused. Options since removed from the menu are left out.
|
||||||
buyAgainOptionIds: Array<number>
|
buyAgainOptionIds: Array<number>
|
||||||
// The box a ready item waits in, still reported once it's picked up (with
|
// The box a ready item waits in, still reported once it's picked up or
|
||||||
// isOpen false, since the box may since have gone to another item).
|
// discarded (with isOpen false, since the box may since have gone to
|
||||||
|
// another item).
|
||||||
pickupBox: { number: number; isOpen: boolean } | null
|
pickupBox: { number: number; isOpen: boolean } | null
|
||||||
pickedUpAt: Date | null
|
pickedUpAt: Date | null
|
||||||
|
// When the box will be emptied if the item is still there.
|
||||||
|
pickupDeadline: Date | null
|
||||||
etaSeconds: number | null
|
etaSeconds: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,6 +986,8 @@ export const getOrderStatus = async (
|
|||||||
orderId: number,
|
orderId: number,
|
||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
|
await discardUncollectedItems(db)
|
||||||
|
|
||||||
const order = firstRow(
|
const order = firstRow(
|
||||||
await db.select().from(schema.orders).where(eq(schema.orders.id, orderId)),
|
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))
|
.where(inArray(schema.orderItemOptions.orderItemId, orderItemIds))
|
||||||
|
|
||||||
const pickupBoxIds = items
|
const pickupBoxIds = items
|
||||||
.filter((item) => item.status === 'ready' || item.status === 'picked_up')
|
|
||||||
.map((item) => item.pickupBoxId)
|
.map((item) => item.pickupBoxId)
|
||||||
.filter((id) => id !== null)
|
.filter((id) => id !== null)
|
||||||
const pickupBoxesById = new Map(
|
const pickupBoxesById = new Map(
|
||||||
@@ -1134,7 +1201,8 @@ export const getOrderStatus = async (
|
|||||||
if (
|
if (
|
||||||
item.status === 'ready' ||
|
item.status === 'ready' ||
|
||||||
item.status === 'picked_up' ||
|
item.status === 'picked_up' ||
|
||||||
item.status === 'cancelled'
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'discarded'
|
||||||
) {
|
) {
|
||||||
etaSeconds = null
|
etaSeconds = null
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1212,6 @@ export const getOrderStatus = async (
|
|||||||
(option) => option.orderItemId === item.id,
|
(option) => option.orderItemId === item.id,
|
||||||
)
|
)
|
||||||
const pickupBox =
|
const pickupBox =
|
||||||
(item.status === 'ready' || item.status === 'picked_up') &&
|
|
||||||
item.pickupBoxId !== null
|
item.pickupBoxId !== null
|
||||||
? pickupBoxesById.get(item.pickupBoxId)
|
? pickupBoxesById.get(item.pickupBoxId)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -1158,6 +1225,7 @@ export const getOrderStatus = async (
|
|||||||
item.status !== 'cancelled' &&
|
item.status !== 'cancelled' &&
|
||||||
item.status !== 'ready' &&
|
item.status !== 'ready' &&
|
||||||
item.status !== 'picked_up' &&
|
item.status !== 'picked_up' &&
|
||||||
|
item.status !== 'discarded' &&
|
||||||
currentInstance?.status === 'queued',
|
currentInstance?.status === 'queued',
|
||||||
steps,
|
steps,
|
||||||
options: options.map(({ name, priceDeltaCents }) => ({
|
options: options.map(({ name, priceDeltaCents }) => ({
|
||||||
@@ -1179,6 +1247,10 @@ export const getOrderStatus = async (
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
pickedUpAt: item.pickedUpAt,
|
pickedUpAt: item.pickedUpAt,
|
||||||
|
pickupDeadline:
|
||||||
|
item.status === 'ready' && item.placedInBoxAt
|
||||||
|
? new Date(item.placedInBoxAt.getTime() + PICKUP_WINDOW_MS)
|
||||||
|
: null,
|
||||||
etaSeconds,
|
etaSeconds,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1198,9 +1270,15 @@ export const getOrderStatus = async (
|
|||||||
export const getOrderProgress = (
|
export const getOrderProgress = (
|
||||||
items: Array<{ status: schema.OrderItemStatus; started: boolean }>,
|
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)
|
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'))
|
if (activeStatuses.every((status) => status === 'picked_up'))
|
||||||
return 'picked_up'
|
return 'picked_up'
|
||||||
if (
|
if (
|
||||||
@@ -1314,6 +1392,7 @@ export const getOrdersForCustomer = async (
|
|||||||
db: Db,
|
db: Db,
|
||||||
customerId: string,
|
customerId: string,
|
||||||
): Promise<Array<CustomerOrderSummary>> => {
|
): Promise<Array<CustomerOrderSummary>> => {
|
||||||
|
await discardUncollectedItems(db)
|
||||||
const orders = await db
|
const orders = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.orders)
|
.from(schema.orders)
|
||||||
@@ -1351,6 +1430,7 @@ export type AdminOrderSummary = CustomerOrderSummary & {
|
|||||||
export const getAllOrders = async (
|
export const getAllOrders = async (
|
||||||
db: Db,
|
db: Db,
|
||||||
): Promise<Array<AdminOrderSummary>> => {
|
): Promise<Array<AdminOrderSummary>> => {
|
||||||
|
await discardUncollectedItems(db)
|
||||||
const orders = await db
|
const orders = await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.orders)
|
.from(schema.orders)
|
||||||
@@ -1384,6 +1464,7 @@ export const getGuestOrderSummaries = async (
|
|||||||
refs: Array<GuestOrderRef>,
|
refs: Array<GuestOrderRef>,
|
||||||
): Promise<Array<CustomerOrderSummary>> => {
|
): Promise<Array<CustomerOrderSummary>> => {
|
||||||
if (refs.length === 0) return []
|
if (refs.length === 0) return []
|
||||||
|
await discardUncollectedItems(db)
|
||||||
|
|
||||||
const tokenByOrderId = new Map(
|
const tokenByOrderId = new Map(
|
||||||
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
|
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
|
||||||
|
|||||||
@@ -63,4 +63,11 @@ export const formatEtaParts = (etaSeconds: number, now = new Date()) =>
|
|||||||
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
|
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
|
||||||
: { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
|
: { 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('_', ' ')
|
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const orderStatusHelp: Record<OrderItemStatus, string> = {
|
|||||||
ready: 'Your order is ready for pickup. If something’s off with it, let us know below.',
|
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.',
|
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.',
|
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 = [
|
const faqs = [
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
describe('formatDuration', () => {
|
||||||
it('rounds up to at least 1 minute', () => {
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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', () => {
|
describe('kitchen simulation', () => {
|
||||||
const policy = (
|
const policy = (
|
||||||
outcome: ReturnType<ordering.KitchenSimulationPolicy['pickOutcome']>,
|
outcome: ReturnType<ordering.KitchenSimulationPolicy['pickOutcome']>,
|
||||||
@@ -1026,6 +1084,7 @@ describe('order history', () => {
|
|||||||
const ready = { status: 'ready', started: true } as const
|
const ready = { status: 'ready', started: true } as const
|
||||||
const pickedUp = { status: 'picked_up', started: true } as const
|
const pickedUp = { status: 'picked_up', started: true } as const
|
||||||
const cancelled = { status: 'cancelled', started: false } as const
|
const cancelled = { status: 'cancelled', started: false } as const
|
||||||
|
const discarded = { status: 'discarded', started: true } as const
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[[waiting, waiting], 'not_started'],
|
[[waiting, waiting], 'not_started'],
|
||||||
@@ -1036,6 +1095,8 @@ describe('order history', () => {
|
|||||||
[[ready, pickedUp], 'ready'],
|
[[ready, pickedUp], 'ready'],
|
||||||
[[cancelled, pickedUp], 'picked_up'],
|
[[cancelled, pickedUp], 'picked_up'],
|
||||||
[[cancelled, cancelled], 'cancelled'],
|
[[cancelled, cancelled], 'cancelled'],
|
||||||
|
[[discarded, pickedUp], 'picked_up'],
|
||||||
|
[[discarded, cancelled], 'discarded'],
|
||||||
] as const)('derives progress from %j as %s', (items, expected) => {
|
] as const)('derives progress from %j as %s', (items, expected) => {
|
||||||
expect(ordering.getOrderProgress([...items])).toBe(expected)
|
expect(ordering.getOrderProgress([...items])).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user