Animate the ETA, hide delays, and tidy the order card actions

- Slide the "Ready in about" value when it changes (new EtaMessage)
- Drop the "Running late" banner; delays only move the ETA now
- Remove the cancel-info tooltip and its icon button
- Shorten the steps toggle to "Steps" / "Hide"
- Add icons and more padding to the order card action buttons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 11:02:35 +02:00
co-authored by Claude Opus 5.5
parent 7cf286850c
commit 1dae53f90a
11 changed files with 104 additions and 151 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ One stage of preparing an Order Item, requiring a Machine carrying a specific Ma
_Avoid_: Stage (use Step consistently) _Avoid_: Stage (use Step consistently)
**Delay**: **Delay**:
Extra time a Machine reports on top of a claimed Preparation Step's estimated duration. Delays add to the Order Item's time to completion and are shown to the customer as the item running late. Extra time a Machine reports on top of a claimed Preparation Step's estimated duration. Delays add to the Order Item's time to completion; the customer only sees the updated time, never a delay notice.
**Machine**: **Machine**:
A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel. A physical cooking hardware unit at a Kitchen (e.g. an oven, a grill), carrying one or more Machine Tags. A Kitchen may have multiple Machines carrying the same Tag, working in parallel.
@@ -0,0 +1,19 @@
@use '../../styles/variables' as *;
.eta {
margin: 0 0 $space-2;
font-size: 0.9rem;
color: $color-success;
}
.value {
position: relative;
display: inline-flex;
overflow: hidden;
vertical-align: bottom;
}
.valueText {
display: inline-block;
white-space: nowrap;
}
+30
View File
@@ -0,0 +1,30 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { formatEtaParts } from '#/lib/format.ts'
import styles from './EtaMessage.module.scss'
export const EtaMessage = ({ etaSeconds }: { etaSeconds: number }) => {
const reduceMotion = useReducedMotion()
const { prefix, value } = formatEtaParts(etaSeconds)
return (
<p className={styles.eta}>
{prefix}{' '}
<span className={styles.value}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={value}
className={styles.valueText}
initial={reduceMotion ? false : { y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { y: '-100%', opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.35, ease: 'easeOut' }}
>
{value}
</motion.span>
</AnimatePresence>
</span>
</p>
)
}
@@ -9,10 +9,6 @@
&[data-variant='cancelled'] { &[data-variant='cancelled'] {
color: $color-danger; color: $color-danger;
} }
&[data-variant='delayed'] {
color: $color-warning;
}
} }
.icon { .icon {
+1 -3
View File
@@ -2,14 +2,12 @@ import { motion, useReducedMotion } from 'framer-motion'
import styles from './ItemStatusBanner.module.scss' import styles from './ItemStatusBanner.module.scss'
export type ItemStatusBannerVariant = export type ItemStatusBannerVariant = 'ready' | 'pickedUp' | 'cancelled'
'ready' | 'pickedUp' | 'cancelled' | 'delayed'
const iconPaths = { const iconPaths = {
ready: ['M7.5 12.5l3 3 6-6.5'], ready: ['M7.5 12.5l3 3 6-6.5'],
pickedUp: ['M7.5 12.5l3 3 6-6.5'], pickedUp: ['M7.5 12.5l3 3 6-6.5'],
cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'], cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'],
delayed: ['M12 7v5', 'M12 12l3.5 2'],
} satisfies Record<ItemStatusBannerVariant, Array<string>> } satisfies Record<ItemStatusBannerVariant, Array<string>>
type ItemStatusBannerProps = { type ItemStatusBannerProps = {
@@ -1,13 +1,5 @@
@use '../../styles/variables' as *; @use '../../styles/variables' as *;
@position-try --cancel-hint-flip {
position-area: bottom;
--hint-arrow-top: auto;
--hint-arrow-bottom: 100%;
--hint-arrow-color-top: transparent;
--hint-arrow-color-bottom: #{$color-text};
}
.page { .page {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
@@ -277,12 +269,6 @@
opacity: 0.6; opacity: 0.6;
} }
.eta {
margin: 0 0 $space-2;
font-size: 0.9rem;
color: $color-success;
}
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -291,7 +277,10 @@
button, button,
a { a {
padding: $space-1 $space-3; display: inline-flex;
align-items: center;
gap: $space-1;
padding: $space-2 $space-4;
border: 1px solid $color-border; border: 1px solid $color-border;
border-radius: $radius; border-radius: $radius;
background: $color-background; background: $color-background;
@@ -310,66 +299,12 @@
} }
} }
.infoButton { .actionIcon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
gap: $space-1;
padding: $space-1 $space-2;
color: $color-text-muted;
font-size: 0.85rem;
white-space: nowrap;
&:hover:not(:disabled) {
color: $color-primary;
border-color: $color-primary;
}
}
.infoIcon {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
flex-shrink: 0; flex-shrink: 0;
} }
.cancelHint {
position: fixed;
position-area: top;
position-try-fallbacks: --cancel-hint-flip;
inset: auto;
overflow: visible;
height: auto;
margin: $space-2;
padding: $space-2 $space-3;
width: max-content;
max-width: 260px;
border: none;
border-radius: $radius;
background: $color-text;
color: $color-background;
font-size: 0.8rem;
line-height: 1.4;
text-align: left;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
--hint-arrow-top: 100%;
--hint-arrow-bottom: auto;
--hint-arrow-color-top: #{$color-text};
--hint-arrow-color-bottom: transparent;
&::after {
content: '';
position: absolute;
left: 50%;
top: var(--hint-arrow-top);
bottom: var(--hint-arrow-bottom);
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--hint-arrow-color-top);
border-bottom-color: var(--hint-arrow-color-bottom);
}
}
.cancelButton { .cancelButton {
padding: $space-1 $space-2; padding: $space-1 $space-2;
border: none; border: none;
+27 -60
View File
@@ -4,7 +4,6 @@ import { useState } from 'react'
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 { formatDuration, formatEtaMessage } from '#/lib/format.ts'
import { import {
advanceSimulatedKitchen, advanceSimulatedKitchen,
cancelOrderItem, cancelOrderItem,
@@ -13,6 +12,7 @@ import {
} from '#/server/orders.ts' } from '#/server/orders.ts'
import styles from './OrderStatusView.module.scss' import styles from './OrderStatusView.module.scss'
import { EtaMessage } from './EtaMessage.tsx'
import { ItemStatusBanner } from './ItemStatusBanner.tsx' import { ItemStatusBanner } from './ItemStatusBanner.tsx'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -55,14 +55,6 @@ const getItemBanner = (item: OrderItemStatusView) => {
detail: "You've been refunded for this item.", detail: "You've been refunded for this item.",
} as const) } as const)
} }
if (item.delaySeconds > 0) {
return {
animationKey: 'delayed',
variant: 'delayed',
label: 'Running late',
detail: `About ${formatDuration(item.delaySeconds)} behind schedule`,
} as const
}
return null return null
} }
@@ -71,20 +63,6 @@ const bannerKey = (item: OrderItemStatusView) => {
return banner ? `${item.id}:${banner.animationKey}` : null return banner ? `${item.id}:${banner.animationKey}` : null
} }
const cancelBlockedReason = (item: OrderItemStatusView) => {
if (
item.cancellable ||
item.status === 'cancelled' ||
item.status === 'picked_up'
) {
return null
}
if (item.status === 'ready') {
return "This item is already done cooking, so it's too late to cancel. If something's wrong with it, use Get help below."
}
return "This item has already started cooking, so the kitchen can't stop or undo it. If something's wrong, use Get help below."
}
const BackArrowIcon = () => ( const BackArrowIcon = () => (
<svg <svg
className={styles.backArrow} className={styles.backArrow}
@@ -101,9 +79,24 @@ const BackArrowIcon = () => (
</svg> </svg>
) )
const InfoIcon = () => ( const actionIconPaths = {
buyAgain: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5'],
pickedUp: [
'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z',
'M3 6h18',
'M16 10a4 4 0 0 1-8 0',
],
help: [
'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3',
'M12 17h.01',
],
cancel: ['M18 6 6 18', 'M6 6l12 12'],
}
const ActionIcon = ({ name }: { name: keyof typeof actionIconPaths }) => (
<svg <svg
className={styles.infoIcon} className={styles.actionIcon}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -112,9 +105,9 @@ const InfoIcon = () => (
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" aria-hidden="true"
> >
<circle cx="12" cy="12" r="9" /> {actionIconPaths[name].map((d) => (
<line x1="12" y1="11" x2="12" y2="16" /> <path key={d} d={d} />
<line x1="12" y1="8" x2="12.01" y2="8" /> ))}
</svg> </svg>
) )
@@ -262,7 +255,6 @@ 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 cancelHint = cancelBlockedReason(item)
const banner = getItemBanner(item) const banner = getItemBanner(item)
return ( return (
<li key={item.id} className={styles.item}> <li key={item.id} className={styles.item}>
@@ -340,17 +332,13 @@ export const OrderStatusView = ({
aria-controls={`steps-${item.id}`} aria-controls={`steps-${item.id}`}
onClick={() => toggleSteps(item.id)} onClick={() => toggleSteps(item.id)}
> >
{expandedItemIds.has(item.id) {expandedItemIds.has(item.id) ? 'Hide' : 'Steps'}
? 'Hide steps'
: 'Show steps'}
</button> </button>
)} )}
</div> </div>
)} )}
{item.etaSeconds !== null && ( {item.etaSeconds !== null && (
<p className={styles.eta}> <EtaMessage etaSeconds={item.etaSeconds} />
{formatEtaMessage(item.etaSeconds)}
</p>
)} )}
<div className={styles.actions}> <div className={styles.actions}>
<button <button
@@ -360,6 +348,7 @@ export const OrderStatusView = ({
// //
}} }}
> >
<ActionIcon name="buyAgain" />
Buy again Buy again
</button> </button>
@@ -375,7 +364,7 @@ export const OrderStatusView = ({
) )
} }
> >
I picked this up <ActionIcon name="pickedUp" />I picked this up
</button> </button>
)} )}
<Link <Link
@@ -386,32 +375,9 @@ export const OrderStatusView = ({
status: item.status, status: item.status,
}} }}
> >
<ActionIcon name="help" />
Get help Get help
</Link> </Link>
{cancelHint && (
<>
<button
type="button"
className={styles.infoButton}
popoverTarget={`cancel-hint-${item.id}`}
style={{ anchorName: `--cancel-hint-anchor-${item.id}` }}
>
<InfoIcon />
Why can't I cancel?
</button>
<p
id={`cancel-hint-${item.id}`}
popover="auto"
role="tooltip"
className={styles.cancelHint}
style={{
positionAnchor: `--cancel-hint-anchor-${item.id}`,
}}
>
{cancelHint}
</p>
</>
)}
{item.cancellable && ( {item.cancellable && (
<button <button
type="button" type="button"
@@ -425,6 +391,7 @@ export const OrderStatusView = ({
) )
} }
> >
<ActionIcon name="cancel" />
Cancel Cancel
</button> </button>
)} )}
-3
View File
@@ -789,8 +789,6 @@ export type OrderItemStatusView = {
menuItemName: string menuItemName: string
status: schema.OrderItemStatus status: schema.OrderItemStatus
cancelledBy: schema.OrderItemCancelledBy | null cancelledBy: schema.OrderItemCancelledBy | null
// Total delay reported across the item's Steps so far.
delaySeconds: number
cancellable: boolean cancellable: boolean
steps: Array<{ steps: Array<{
sequence: number sequence: number
@@ -1016,7 +1014,6 @@ export const getOrderStatus = async (
menuItemName: menuItem?.name ?? '', menuItemName: menuItem?.name ?? '',
status: item.status, status: item.status,
cancelledBy: item.cancelledBy, cancelledBy: item.cancelledBy,
delaySeconds: instances.reduce((sum, row) => sum + row.delaySeconds, 0),
cancellable: cancellable:
item.status !== 'cancelled' && item.status !== 'cancelled' &&
item.status !== 'ready' && item.status !== 'ready' &&
+3 -3
View File
@@ -58,9 +58,9 @@ export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
const ABSOLUTE_TIME_THRESHOLD_SECONDS = 3 * 60 * 60 const ABSOLUTE_TIME_THRESHOLD_SECONDS = 3 * 60 * 60
export const formatEtaMessage = (etaSeconds: number, now = new Date()) => export const formatEtaParts = (etaSeconds: number, now = new Date()) =>
etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS etaSeconds > ABSOLUTE_TIME_THRESHOLD_SECONDS
? `Ready ${formatEtaClock(etaSeconds, now)}` ? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
: `Ready in about ${formatDuration(etaSeconds)}` : { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
export const formatStatus = (status: string) => status.replaceAll('_', ' ') export const formatStatus = (status: string) => status.replaceAll('_', ' ')
+18 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { formatDuration, formatEtaClock, formatEtaMessage } from '#/lib/format.ts' import { 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', () => {
@@ -39,16 +39,28 @@ describe('formatEtaClock', () => {
}) })
}) })
describe('formatEtaMessage', () => { describe('formatEtaParts', () => {
const noon = new Date(2026, 0, 15, 12, 0, 0) const noon = new Date(2026, 0, 15, 12, 0, 0)
it('shows the relative time at or under the 3 hour threshold', () => { it('shows the relative time at or under the 3 hour threshold', () => {
expect(formatEtaMessage(34 * 60, noon)).toBe('Ready in about 34 min') expect(formatEtaParts(34 * 60, noon)).toEqual({
expect(formatEtaMessage(3 * 60 * 60, noon)).toBe('Ready in about 3 hr') prefix: 'Ready in about',
value: '34 min',
})
expect(formatEtaParts(3 * 60 * 60, noon)).toEqual({
prefix: 'Ready in about',
value: '3 hr',
})
}) })
it('shows the absolute time once the eta passes 3 hours', () => { it('shows the absolute time once the eta passes 3 hours', () => {
expect(formatEtaMessage(3 * 60 * 60 + 60, noon)).toBe('Ready today, 03:01 PM') expect(formatEtaParts(3 * 60 * 60 + 60, noon)).toEqual({
expect(formatEtaMessage(230 * 60, noon)).toBe('Ready today, 03:50 PM') prefix: 'Ready',
value: 'today, 03:01 PM',
})
expect(formatEtaParts(230 * 60, noon)).toEqual({
prefix: 'Ready',
value: 'today, 03:50 PM',
})
}) })
}) })
-1
View File
@@ -667,7 +667,6 @@ describe('kitchen simulation', () => {
const after = (await getStatus(order)).items[0] const after = (await getStatus(order)).items[0]
expect(after.steps[0].status).toBe('in_progress') expect(after.steps[0].status).toBe('in_progress')
expect(after.delaySeconds).toBe(180)
expect(after.etaSeconds).toBeGreaterThanOrEqual(before.etaSeconds! + 170) expect(after.etaSeconds).toBeGreaterThanOrEqual(before.etaSeconds! + 170)
}) })