From 1dae53f90ad01923c327777957e7fb37e6089798 Mon Sep 17 00:00:00 2001 From: lukas Date: Thu, 1 Oct 2026 11:02:35 +0200 Subject: [PATCH] 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 --- CONTEXT.md | 2 +- src/components/orders/EtaMessage.module.scss | 19 ++++ src/components/orders/EtaMessage.tsx | 30 +++++++ .../orders/ItemStatusBanner.module.scss | 4 - src/components/orders/ItemStatusBanner.tsx | 4 +- .../orders/OrderStatusView.module.scss | 75 ++-------------- src/components/orders/OrderStatusView.tsx | 87 ++++++------------- src/domain/ordering.ts | 3 - src/lib/format.ts | 6 +- tests/format.test.ts | 24 +++-- tests/ordering.test.ts | 1 - 11 files changed, 104 insertions(+), 151 deletions(-) create mode 100644 src/components/orders/EtaMessage.module.scss create mode 100644 src/components/orders/EtaMessage.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 712cd7a..4809491 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/src/components/orders/EtaMessage.module.scss b/src/components/orders/EtaMessage.module.scss new file mode 100644 index 0000000..47e5531 --- /dev/null +++ b/src/components/orders/EtaMessage.module.scss @@ -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; +} diff --git a/src/components/orders/EtaMessage.tsx b/src/components/orders/EtaMessage.tsx new file mode 100644 index 0000000..bb6a435 --- /dev/null +++ b/src/components/orders/EtaMessage.tsx @@ -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 ( +

+ {prefix}{' '} + + + + {value} + + + +

+ ) +} diff --git a/src/components/orders/ItemStatusBanner.module.scss b/src/components/orders/ItemStatusBanner.module.scss index 872bd80..047cd29 100644 --- a/src/components/orders/ItemStatusBanner.module.scss +++ b/src/components/orders/ItemStatusBanner.module.scss @@ -9,10 +9,6 @@ &[data-variant='cancelled'] { color: $color-danger; } - - &[data-variant='delayed'] { - color: $color-warning; - } } .icon { diff --git a/src/components/orders/ItemStatusBanner.tsx b/src/components/orders/ItemStatusBanner.tsx index 0f42fe1..9f6e8ac 100644 --- a/src/components/orders/ItemStatusBanner.tsx +++ b/src/components/orders/ItemStatusBanner.tsx @@ -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> type ItemStatusBannerProps = { diff --git a/src/components/orders/OrderStatusView.module.scss b/src/components/orders/OrderStatusView.module.scss index 4cb434d..f74a5ef 100644 --- a/src/components/orders/OrderStatusView.module.scss +++ b/src/components/orders/OrderStatusView.module.scss @@ -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; diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index 53f9a7f..281ff27 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -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 = () => ( ( ) -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 }) => ( ( strokeLinejoin="round" aria-hidden="true" > - - - + {actionIconPaths[name].map((d) => ( + + ))} ) @@ -262,7 +255,6 @@ export const OrderStatusView = ({ {error &&

{error}

}
    {status.items.map((item) => { - const cancelHint = cancelBlockedReason(item) const banner = getItemBanner(item) return (
  • @@ -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'} )} )} {item.etaSeconds !== null && ( -

    - {formatEtaMessage(item.etaSeconds)} -

    + )}
    @@ -375,7 +364,7 @@ export const OrderStatusView = ({ ) } > - I picked this up + I picked this up )} + Get help - {cancelHint && ( - <> - - - - )} {item.cancellable && ( )} diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index 317430a..3df6191 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -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' && diff --git a/src/lib/format.ts b/src/lib/format.ts index 9e37b96..97f63dc 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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('_', ' ') diff --git a/tests/format.test.ts b/tests/format.test.ts index 90eac65..e8a2f38 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -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', + }) }) }) diff --git a/tests/ordering.test.ts b/tests/ordering.test.ts index c366395..bc4a05e 100644 --- a/tests/ordering.test.ts +++ b/tests/ordering.test.ts @@ -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) })