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:
2026-10-01 23:26:37 +02:00
co-authored by Claude Opus 5.5
parent 440f9e5696
commit 6fc315a5f2
13 changed files with 1248 additions and 38 deletions
@@ -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;
+98 -23
View File
@@ -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 }) => (
</svg>
)
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 (
<svg
<motion.svg
className={`${styles.actionIcon} ${styles.lockIcon}`}
viewBox="0 0 24 24"
fill="none"
@@ -138,14 +160,34 @@ const LockIcon = ({ open }: { open: boolean }) => {
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' }
}
>
<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
initial={false}
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(
() => 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<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>>(
new Set(),
)
@@ -343,7 +412,7 @@ export const OrderStatusView = ({
{error && <p className={styles.error}>{error}</p>}
<ul className={styles.items}>
{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}
>
<LockIcon open={pickupBox.isOpen} />
{boxClosed
? `Box ${pickupBox.number} closed`
: pickupBox.isOpen
? `Hold to close box ${pickupBox.number}`
: `Hold to open box ${pickupBox.number}`}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={pickupButtonLabel(pickupBox, boxClosed)}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: reduceMotion ? 0 : 0.18 }}
>
{pickupButtonLabel(pickupBox, boxClosed)}
</motion.span>
</AnimatePresence>
</HoldButton>
)}
</AnimatePresence>
@@ -138,7 +138,8 @@
color: $color-success;
}
&[data-progress='cancelled'] {
&[data-progress='cancelled'],
&[data-progress='discarded'] {
color: $color-danger;
}
}
+4
View File
@@ -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<OrderItemCancelledBy>(),
// 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(),
})
+92 -11
View File
@@ -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<number>
// 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<Array<CustomerOrderSummary>> => {
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<Array<AdminOrderSummary>> => {
await discardUncollectedItems(db)
const orders = await db
.select()
.from(schema.orders)
@@ -1384,6 +1464,7 @@ export const getGuestOrderSummaries = async (
refs: Array<GuestOrderRef>,
): Promise<Array<CustomerOrderSummary>> => {
if (refs.length === 0) return []
await discardUncollectedItems(db)
const tokenByOrderId = new Map(
refs.map((ref) => [ref.orderId, ref.guestAccessToken]),
+7
View File
@@ -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('_', ' ')
+1
View File
@@ -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.',
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 = [