Merge pull request 'fix(cart): make the reservation countdown tick, and warn against the real hold (#97)' (#152) from feature/97-cart-countdown into main
Reviewed-on: #152
This commit was merged in pull request #152.
This commit is contained in:
+29
-10
@@ -25,18 +25,16 @@ import { fetchConfig, SiteConfig } from '../api';
|
|||||||
import { loadPaypalSdk } from '../paypal';
|
import { loadPaypalSdk } from '../paypal';
|
||||||
import { useCart } from './CartContext';
|
import { useCart } from './CartContext';
|
||||||
import { useCustomerAuth } from '../customer/CustomerAuthContext';
|
import { useCustomerAuth } from '../customer/CustomerAuthContext';
|
||||||
|
import { useNow } from './useNow';
|
||||||
|
import { timeRemaining, isExpiringSoon, hasLapsedItem } from './reservation';
|
||||||
|
|
||||||
|
// The display has one-minute resolution, so half a minute keeps it honest
|
||||||
|
// without being busy. Once a second would be wasted work.
|
||||||
|
const COUNTDOWN_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
function timeRemaining(expiresAt: string): string {
|
|
||||||
const diffMs = new Date(expiresAt).getTime() - Date.now();
|
|
||||||
if (diffMs <= 0) return 'expiring…';
|
|
||||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
return `${hours}h ${mins}m left`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Cart() {
|
export default function Cart() {
|
||||||
const [items, setItems] = useState<CartItem[]>([]);
|
const [items, setItems] = useState<CartItem[]>([]);
|
||||||
const [addresses, setAddresses] = useState<ShippingAddress[]>([]);
|
const [addresses, setAddresses] = useState<ShippingAddress[]>([]);
|
||||||
@@ -71,6 +69,27 @@ export default function Cart() {
|
|||||||
|
|
||||||
useEffect(() => { if (customer) loadAll(); }, [customer]);
|
useEffect(() => { if (customer) loadAll(); }, [customer]);
|
||||||
|
|
||||||
|
// Only ticks while there is something to count down, so an empty cart does
|
||||||
|
// not wake React forever.
|
||||||
|
const now = useNow(COUNTDOWN_INTERVAL_MS, items.length > 0);
|
||||||
|
const lapsed = hasLapsedItem(items, now);
|
||||||
|
|
||||||
|
// Past the deadline the item is still held until the server's sweep releases
|
||||||
|
// it, and the client has no way to know when that lands. Refetching on each
|
||||||
|
// tick while anything is lapsed means the row clears within one interval of
|
||||||
|
// the release rather than sitting at "expiring…" until the page is reloaded.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lapsed) return;
|
||||||
|
loadAll();
|
||||||
|
// The header badge counts held items too, so it goes stale in exactly the
|
||||||
|
// same way. Safe as a dependency: CartContext memoizes it with an empty
|
||||||
|
// dependency list, so it is stable across renders and cannot re-trigger
|
||||||
|
// this effect on its own.
|
||||||
|
refreshCartContext();
|
||||||
|
// `now` is what paces this: it changes once per tick, and re-running while
|
||||||
|
// an item is lapsed is the point.
|
||||||
|
}, [lapsed, now, refreshCartContext]);
|
||||||
|
|
||||||
// compute the total
|
// compute the total
|
||||||
const total = items.reduce((sum, i) => sum + i.price_cents, 0);
|
const total = items.reduce((sum, i) => sum + i.price_cents, 0);
|
||||||
|
|
||||||
@@ -174,8 +193,8 @@ export default function Cart() {
|
|||||||
avatar={item.images[0] && <img src={item.images[0].image_path} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />}
|
avatar={item.images[0] && <img src={item.images[0].image_path} alt="" style={{ width: 60, height: 60, objectFit: 'cover' }} />}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
description={
|
description={
|
||||||
<Text type={new Date(item.expires_at).getTime() - Date.now() < 60 * 60 * 1000 ? 'danger' : 'secondary'}>
|
<Text type={isExpiringSoon(item.added_at, item.expires_at, now) ? 'danger' : 'secondary'}>
|
||||||
{timeRemaining(item.expires_at)}
|
{timeRemaining(item.expires_at, now)}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* How a held item's remaining time is described, and when it is worth warning
|
||||||
|
* about.
|
||||||
|
*
|
||||||
|
* Kept apart from the component so both can be exercised without rendering a
|
||||||
|
* cart, and because the warning rule below is a judgement rather than a
|
||||||
|
* formatting detail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Warn over the last tenth of the hold, whatever the hold happens to be. */
|
||||||
|
const WARNING_FRACTION = 0.1;
|
||||||
|
|
||||||
|
export function timeRemaining(expiresAt: string, now: number): string {
|
||||||
|
const diffMs = new Date(expiresAt).getTime() - now;
|
||||||
|
// Not "expired": the server releases items on a sweep every few minutes, so
|
||||||
|
// between the deadline and that sweep the item is genuinely still held. This
|
||||||
|
// claims imminence, which is true, rather than completion, which is not ours
|
||||||
|
// to say.
|
||||||
|
if (diffMs <= 0) return 'expiring…';
|
||||||
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
return `${hours}h ${mins}m left`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this reservation is close enough to lapsing to say so in red.
|
||||||
|
*
|
||||||
|
* Derived from the item's own `added_at` and `expires_at` rather than from a
|
||||||
|
* constant. The hold is admin-configurable since #136 and accepts values as low
|
||||||
|
* as half an hour, so the previous hardcoded "final hour" meant every item was
|
||||||
|
* red from the moment it was reserved on any setting below one hour — a warning
|
||||||
|
* that is always on is not a warning.
|
||||||
|
*
|
||||||
|
* Reading it from the item also means an admin changing the setting does not
|
||||||
|
* retroactively relabel a reservation that was granted under the old one.
|
||||||
|
*/
|
||||||
|
export function isExpiringSoon(addedAt: string, expiresAt: string, now: number): boolean {
|
||||||
|
const expires = new Date(expiresAt).getTime();
|
||||||
|
const holdMs = expires - new Date(addedAt).getTime();
|
||||||
|
const remainingMs = expires - now;
|
||||||
|
if (remainingMs <= 0) return true;
|
||||||
|
// A nonsensical hold (equal or inverted timestamps) should not make every
|
||||||
|
// item permanently urgent; fall back to not warning rather than always.
|
||||||
|
if (holdMs <= 0) return false;
|
||||||
|
return remainingMs <= holdMs * WARNING_FRACTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once any held item is past its deadline and awaiting the server sweep. */
|
||||||
|
export function hasLapsedItem(items: { expires_at: string }[], now: number): boolean {
|
||||||
|
return items.some((item) => new Date(item.expires_at).getTime() - now <= 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current time, as state that advances on an interval.
|
||||||
|
*
|
||||||
|
* Returns a value rather than merely forcing a re-render, and that is the point.
|
||||||
|
* A component that calls `Date.now()` while rendering produces output that
|
||||||
|
* depends on the clock, which React is entitled to assume it does not — the
|
||||||
|
* `react-hooks/purity` rule says so, and it was the only instance in this
|
||||||
|
* codebase precisely because it was the only place doing it. Reading `now` from
|
||||||
|
* state makes render a function of its inputs again.
|
||||||
|
*
|
||||||
|
* It also fixes the defect underneath that rule. Nothing scheduled a re-render,
|
||||||
|
* so the cart's countdown was a still photograph of the moment the page loaded,
|
||||||
|
* and the red warning in the final stretch could only appear by accident — the
|
||||||
|
* component had already rendered before the final stretch began.
|
||||||
|
*
|
||||||
|
* `active` exists so an idle page is not waking React forever: an empty cart has
|
||||||
|
* nothing to count down, so it does not tick at all.
|
||||||
|
*/
|
||||||
|
export function useNow(intervalMs: number, active: boolean): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
// Set immediately as well as on the interval: mounting with `active` already
|
||||||
|
// true would otherwise show a value up to intervalMs stale.
|
||||||
|
setNow(Date.now());
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), intervalMs);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [intervalMs, active]);
|
||||||
|
|
||||||
|
return now;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect, createItem, uniqueSuffix } from './fixtures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The countdown is the one part of this app whose output depends on the wall
|
||||||
|
* clock, so these use Playwright's clock control rather than waiting in real
|
||||||
|
* time. Installing it also makes the assertions deterministic: without it, "the
|
||||||
|
* text changed" would depend on where in the minute the test happened to start.
|
||||||
|
*/
|
||||||
|
test.describe('The cart reservation countdown', () => {
|
||||||
|
test('counts down on its own, with no interaction', async ({ page, customer, cart }) => {
|
||||||
|
const name = `Held c${uniqueSuffix()}`;
|
||||||
|
const item = await createItem(page.request, { name, price: '80' });
|
||||||
|
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
|
||||||
|
|
||||||
|
// Installed before navigating: the interval is created on mount, and a clock
|
||||||
|
// swapped in afterwards would not be the one it is scheduled against.
|
||||||
|
await page.clock.install();
|
||||||
|
await cart.goto();
|
||||||
|
|
||||||
|
const before = await cart.remaining(name).textContent();
|
||||||
|
expect(before).toMatch(/left/);
|
||||||
|
|
||||||
|
// Two minutes, which is four ticks of the 30s interval and enough to move a
|
||||||
|
// display with one-minute resolution.
|
||||||
|
await page.clock.fastForward('02:00');
|
||||||
|
|
||||||
|
// The assertion the issue exists for: the text changes with nothing
|
||||||
|
// clicked, reloaded or navigated. Before this, it was a still photograph of
|
||||||
|
// the moment the page rendered.
|
||||||
|
await expect(cart.remaining(name)).not.toHaveText(before ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns near the end of the hold rather than for the whole of it', async ({
|
||||||
|
page,
|
||||||
|
customer,
|
||||||
|
cart
|
||||||
|
}) => {
|
||||||
|
const name = `Waning c${uniqueSuffix()}`;
|
||||||
|
const item = await createItem(page.request, { name, price: '80' });
|
||||||
|
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
|
||||||
|
|
||||||
|
await page.clock.install();
|
||||||
|
await cart.goto();
|
||||||
|
|
||||||
|
// Freshly reserved, so it is not urgent. antd renders `type="danger"` as a
|
||||||
|
// class rather than an attribute, which is why this asserts on the class.
|
||||||
|
await expect(cart.remaining(name)).not.toHaveClass(/ant-typography-danger/);
|
||||||
|
|
||||||
|
// Past 90% of the default 24-hour hold, so inside the final tenth. The old
|
||||||
|
// hardcoded "final hour" threshold would have been silent here.
|
||||||
|
await page.clock.fastForward('22:00:00');
|
||||||
|
await expect(cart.remaining(name)).toHaveClass(/ant-typography-danger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says the hold is expiring once it is past, rather than freezing', async ({
|
||||||
|
page,
|
||||||
|
customer,
|
||||||
|
cart
|
||||||
|
}) => {
|
||||||
|
const name = `Lapsing c${uniqueSuffix()}`;
|
||||||
|
const item = await createItem(page.request, { name, price: '80' });
|
||||||
|
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
|
||||||
|
|
||||||
|
await page.clock.install();
|
||||||
|
await cart.goto();
|
||||||
|
|
||||||
|
// Past the deadline. The server releases held items on a sweep every few
|
||||||
|
// minutes, so between the deadline and that sweep the item is genuinely
|
||||||
|
// still held — "expiring…" claims imminence rather than completion.
|
||||||
|
await page.clock.fastForward('25:00:00');
|
||||||
|
|
||||||
|
await expect(cart.remaining(name)).toHaveText('expiring…');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { AdminTaxonomy } from './pages/AdminTaxonomy';
|
|||||||
import { AdminCustomers } from './pages/AdminCustomers';
|
import { AdminCustomers } from './pages/AdminCustomers';
|
||||||
import { AdminEmails } from './pages/AdminEmails';
|
import { AdminEmails } from './pages/AdminEmails';
|
||||||
import { AdminSettings } from './pages/AdminSettings';
|
import { AdminSettings } from './pages/AdminSettings';
|
||||||
|
import { CartPage } from './pages/CartPage';
|
||||||
import { uniqueEmail } from './support/api';
|
import { uniqueEmail } from './support/api';
|
||||||
|
|
||||||
// Re-exported so specs can import everything from here — expect, Page,
|
// Re-exported so specs can import everything from here — expect, Page,
|
||||||
@@ -37,6 +38,7 @@ export { AdminTaxonomy } from './pages/AdminTaxonomy';
|
|||||||
export { AdminCustomers } from './pages/AdminCustomers';
|
export { AdminCustomers } from './pages/AdminCustomers';
|
||||||
export { AdminEmails } from './pages/AdminEmails';
|
export { AdminEmails } from './pages/AdminEmails';
|
||||||
export { AdminSettings } from './pages/AdminSettings';
|
export { AdminSettings } from './pages/AdminSettings';
|
||||||
|
export { CartPage } from './pages/CartPage';
|
||||||
|
|
||||||
const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output');
|
const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output');
|
||||||
const collectingCoverage = process.env.COVERAGE === 'true';
|
const collectingCoverage = process.env.COVERAGE === 'true';
|
||||||
@@ -70,6 +72,7 @@ interface Pages {
|
|||||||
adminCustomers: AdminCustomers;
|
adminCustomers: AdminCustomers;
|
||||||
adminEmails: AdminEmails;
|
adminEmails: AdminEmails;
|
||||||
adminSettings: AdminSettings;
|
adminSettings: AdminSettings;
|
||||||
|
cart: CartPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
@@ -147,6 +150,9 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
|
|||||||
adminSettings: async ({ page }, use) => {
|
adminSettings: async ({ page }, use) => {
|
||||||
await use(new AdminSettings(page));
|
await use(new AdminSettings(page));
|
||||||
},
|
},
|
||||||
|
cart: async ({ page }, use) => {
|
||||||
|
await use(new CartPage(page));
|
||||||
|
},
|
||||||
|
|
||||||
adminApi: async ({ playwright, baseURL }, use) => {
|
adminApi: async ({ playwright, baseURL }, use) => {
|
||||||
const context = await playwright.request.newContext({ baseURL });
|
const context = await playwright.request.newContext({ baseURL });
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Locator, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cart, where the reservation countdown lives.
|
||||||
|
*
|
||||||
|
* The countdown is the reason this page object exists: it is the one place in
|
||||||
|
* the app whose output depends on the clock, so a test has to be able to name
|
||||||
|
* the text without re-deriving the locator each time.
|
||||||
|
*/
|
||||||
|
export class CartPage {
|
||||||
|
readonly continueShoppingButton: Locator;
|
||||||
|
readonly emptyNotice: Locator;
|
||||||
|
|
||||||
|
constructor(private readonly page: Page) {
|
||||||
|
this.continueShoppingButton = page.getByRole('button', { name: 'Continue Shopping' });
|
||||||
|
this.emptyNotice = page.getByText('Your cart is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto(): Promise<void> {
|
||||||
|
await this.page.goto('/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
row(itemName: string): Locator {
|
||||||
|
return this.page.getByRole('listitem').filter({ hasText: itemName });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "2h 15m left" / "expiring…" line for one held item. */
|
||||||
|
remaining(itemName: string): Locator {
|
||||||
|
return this.row(itemName).getByText(/left|expiring/);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeButton(itemName: string): Locator {
|
||||||
|
return this.row(itemName).getByRole('button', { name: 'Remove' });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user