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:
+1
-1
@@ -38,7 +38,7 @@ One stage of preparing an Order Item, requiring a Machine carrying a specific Ma
|
||||
_Avoid_: Stage (use Step consistently)
|
||||
|
||||
**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**:
|
||||
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;
|
||||
}
|
||||
@@ -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'] {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
&[data-variant='delayed'] {
|
||||
color: $color-warning;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -2,14 +2,12 @@ import { motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
import styles from './ItemStatusBanner.module.scss'
|
||||
|
||||
export type ItemStatusBannerVariant =
|
||||
'ready' | 'pickedUp' | 'cancelled' | 'delayed'
|
||||
export type ItemStatusBannerVariant = 'ready' | 'pickedUp' | 'cancelled'
|
||||
|
||||
const iconPaths = {
|
||||
ready: ['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'],
|
||||
delayed: ['M12 7v5', 'M12 12l3.5 2'],
|
||||
} satisfies Record<ItemStatusBannerVariant, Array<string>>
|
||||
|
||||
type ItemStatusBannerProps = {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
@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 {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
@@ -277,12 +269,6 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.eta {
|
||||
margin: 0 0 $space-2;
|
||||
font-size: 0.9rem;
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -291,7 +277,10 @@
|
||||
|
||||
button,
|
||||
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-radius: $radius;
|
||||
background: $color-background;
|
||||
@@ -310,66 +299,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infoButton {
|
||||
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 {
|
||||
.actionIcon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
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 {
|
||||
padding: $space-1 $space-2;
|
||||
border: none;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState } from 'react'
|
||||
|
||||
import type { OrderStatus } from '#/db/schema.ts'
|
||||
import type { OrderItemStatusView } from '#/domain/ordering.ts'
|
||||
import { formatDuration, formatEtaMessage } from '#/lib/format.ts'
|
||||
import {
|
||||
advanceSimulatedKitchen,
|
||||
cancelOrderItem,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '#/server/orders.ts'
|
||||
|
||||
import styles from './OrderStatusView.module.scss'
|
||||
import { EtaMessage } from './EtaMessage.tsx'
|
||||
import { ItemStatusBanner } from './ItemStatusBanner.tsx'
|
||||
|
||||
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.",
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -71,20 +63,6 @@ const bannerKey = (item: OrderItemStatusView) => {
|
||||
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 = () => (
|
||||
<svg
|
||||
className={styles.backArrow}
|
||||
@@ -101,9 +79,24 @@ const BackArrowIcon = () => (
|
||||
</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
|
||||
className={styles.infoIcon}
|
||||
className={styles.actionIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -112,9 +105,9 @@ const InfoIcon = () => (
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="11" x2="12" y2="16" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
{actionIconPaths[name].map((d) => (
|
||||
<path key={d} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -262,7 +255,6 @@ export const OrderStatusView = ({
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<ul className={styles.items}>
|
||||
{status.items.map((item) => {
|
||||
const cancelHint = cancelBlockedReason(item)
|
||||
const banner = getItemBanner(item)
|
||||
return (
|
||||
<li key={item.id} className={styles.item}>
|
||||
@@ -340,17 +332,13 @@ export const OrderStatusView = ({
|
||||
aria-controls={`steps-${item.id}`}
|
||||
onClick={() => toggleSteps(item.id)}
|
||||
>
|
||||
{expandedItemIds.has(item.id)
|
||||
? 'Hide steps'
|
||||
: 'Show steps'}
|
||||
{expandedItemIds.has(item.id) ? 'Hide' : 'Steps'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{item.etaSeconds !== null && (
|
||||
<p className={styles.eta}>
|
||||
{formatEtaMessage(item.etaSeconds)}
|
||||
</p>
|
||||
<EtaMessage etaSeconds={item.etaSeconds} />
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
@@ -360,6 +348,7 @@ export const OrderStatusView = ({
|
||||
//
|
||||
}}
|
||||
>
|
||||
<ActionIcon name="buyAgain" />
|
||||
Buy again
|
||||
</button>
|
||||
|
||||
@@ -375,7 +364,7 @@ export const OrderStatusView = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
I picked this up
|
||||
<ActionIcon name="pickedUp" />I picked this up
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
@@ -386,32 +375,9 @@ export const OrderStatusView = ({
|
||||
status: item.status,
|
||||
}}
|
||||
>
|
||||
<ActionIcon name="help" />
|
||||
Get help
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -425,6 +391,7 @@ export const OrderStatusView = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
<ActionIcon name="cancel" />
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -789,8 +789,6 @@ export type OrderItemStatusView = {
|
||||
menuItemName: string
|
||||
status: schema.OrderItemStatus
|
||||
cancelledBy: schema.OrderItemCancelledBy | null
|
||||
// Total delay reported across the item's Steps so far.
|
||||
delaySeconds: number
|
||||
cancellable: boolean
|
||||
steps: Array<{
|
||||
sequence: number
|
||||
@@ -1016,7 +1014,6 @@ export const getOrderStatus = async (
|
||||
menuItemName: menuItem?.name ?? '',
|
||||
status: item.status,
|
||||
cancelledBy: item.cancelledBy,
|
||||
delaySeconds: instances.reduce((sum, row) => sum + row.delaySeconds, 0),
|
||||
cancellable:
|
||||
item.status !== 'cancelled' &&
|
||||
item.status !== 'ready' &&
|
||||
|
||||
+3
-3
@@ -58,9 +58,9 @@ export const formatEtaClock = (etaSeconds: number, now = new Date()) => {
|
||||
|
||||
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
|
||||
? `Ready ${formatEtaClock(etaSeconds, now)}`
|
||||
: `Ready in about ${formatDuration(etaSeconds)}`
|
||||
? { prefix: 'Ready', value: formatEtaClock(etaSeconds, now) }
|
||||
: { prefix: 'Ready in about', value: formatDuration(etaSeconds) }
|
||||
|
||||
export const formatStatus = (status: string) => status.replaceAll('_', ' ')
|
||||
|
||||
+18
-6
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatDuration, formatEtaClock, formatEtaMessage } from '#/lib/format.ts'
|
||||
import { formatDuration, formatEtaClock, formatEtaParts } from '#/lib/format.ts'
|
||||
|
||||
describe('formatDuration', () => {
|
||||
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)
|
||||
|
||||
it('shows the relative time at or under the 3 hour threshold', () => {
|
||||
expect(formatEtaMessage(34 * 60, noon)).toBe('Ready in about 34 min')
|
||||
expect(formatEtaMessage(3 * 60 * 60, noon)).toBe('Ready in about 3 hr')
|
||||
expect(formatEtaParts(34 * 60, noon)).toEqual({
|
||||
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', () => {
|
||||
expect(formatEtaMessage(3 * 60 * 60 + 60, noon)).toBe('Ready today, 03:01 PM')
|
||||
expect(formatEtaMessage(230 * 60, noon)).toBe('Ready today, 03:50 PM')
|
||||
expect(formatEtaParts(3 * 60 * 60 + 60, noon)).toEqual({
|
||||
prefix: 'Ready',
|
||||
value: 'today, 03:01 PM',
|
||||
})
|
||||
expect(formatEtaParts(230 * 60, noon)).toEqual({
|
||||
prefix: 'Ready',
|
||||
value: 'today, 03:50 PM',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -667,7 +667,6 @@ describe('kitchen simulation', () => {
|
||||
|
||||
const after = (await getStatus(order)).items[0]
|
||||
expect(after.steps[0].status).toBe('in_progress')
|
||||
expect(after.delaySeconds).toBe(180)
|
||||
expect(after.etaSeconds).toBeGreaterThanOrEqual(before.etaSeconds! + 170)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user