Live picks: collapsible card below the order form, count inside; add e2e tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
simandClaude Sonnet 5.5 committed 2026-11-06 13:42:53 +01:00
1 parent fedf5a45d3
commit a348b759a1
3 files changed
+88 -22

No files matched your search

+21
View File
@@ -49,3 +49,24 @@ test("site admin routes need an ADMIN_EMAILS account", async ({ user, admin }) =
const result = await api("/api/admin/users", { userId: admin.userId });
expect(result.users.length).toBeGreaterThan(0);
});
test("order stats: anonymous counts, podium rank skips the creator", async ({ user }) => {
const order = await createOrder(user.userId);
const stats = (userId?: string) => api(`/api/orders/${order.id}/stats`, { userId });
await submit(user.userId, order.id, { burger: "Classic" });
const others = [await signIn(), await signIn(), await signIn(), await signIn()];
for (const [i, o] of others.entries()) await submit(o.userId, order.id, { burger: i ? "Veggie" : "Classic", sides: ["Fries"] });
const anon = await stats();
expect(anon).toMatchObject({ participant_count: 5, my_rank: null });
expect(anon.categories[0]).toEqual({ label: "Burger", items: [{ name: "Classic", count: 2 }, { name: "Veggie", count: 3 }].sort((a, b) => b.count - a.count) });
expect(JSON.stringify(anon)).not.toMatch(/@|user/i);
expect((await stats(user.userId)).my_rank).toBeNull();
expect((await Promise.all(others.map((o) => stats(o.userId)))).map((s) => s.my_rank)).toEqual([1, 2, 3, null]);
// editing moves you to the back of the line
await api(`/api/orders/${order.id}/submissions/me`, { userId: others[0].userId, method: "PUT", body: { choices: { burger: "Classic" } } });
expect((await stats(others[0].userId)).my_rank).toBeNull();
expect((await stats(others[1].userId)).my_rank).toBe(1);
});
+27 -1
View File
@@ -1,5 +1,5 @@
import { test, expect, loginAs } from "../fixtures";
import { api, createOrder, signIn } from "../lib.mjs";
import { api, createOrder, signIn, submit } from "../lib.mjs";
test("home lists the user's orders", async ({ userPage, user }) => {
await createOrder(user.userId, { title: "Listed on home" });
@@ -44,3 +44,29 @@ test("first visit without an account shows onboarding, not a crash", async ({ pa
await expect(page.getByText("Lunchtime").first()).toBeVisible();
expect(errors).toEqual([]);
});
test("live picks: counts, podium stamp, hide/show, frozen after close", async ({ page, user }) => {
const order = await createOrder(user.userId, { title: "Live picks" });
const first = await signIn();
await submit(first.userId, order.id, { burger: "Veggie" });
await loginAs(page, first);
await page.goto(`/order/${order.id}`);
const card = page.locator(".ant-card", { hasText: "Live picks" }).filter({ hasText: "ordered" });
await expect(card.getByText("1 person has ordered")).toBeVisible();
await expect(card.getByText("Veggie")).toBeVisible();
await expect(page.getByText("You were 1st to order!")).toBeVisible();
// polling picks up another participant without a reload
await submit((await signIn()).userId, order.id, { burger: "Classic" });
await expect(card.getByText("2 people have ordered")).toBeVisible({ timeout: 10_000 });
await card.getByRole("button", { name: "Hide" }).click();
await expect(card.getByText("2 people have ordered")).toBeHidden();
await card.getByRole("button", { name: "Show" }).click();
await expect(card.getByText("2 people have ordered")).toBeVisible();
await api(`/api/orders/${order.id}/admin/status`, { userId: user.userId, method: "PUT", body: { closed: true } });
await expect(page.getByText(/This order is closed/)).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("2 people have ordered")).toBeVisible();
});
+40 -21
View File
@@ -17,6 +17,7 @@ import AsyncContent from "../components/utils/AsyncContent";
import { useApiRequest } from "../hooks/useApiRequest";
import { isNotFoundError } from "../lib/api";
import { navigateTo } from "../lib/routing";
import { getStoredValue, setStoredValue } from "../lib/storage";
import { apiService } from "../lib/services";
import type { OrderStats } from "../lib/services/ordersService";
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
@@ -44,6 +45,7 @@ import ParticipantDraftSummaryAlert from "../components/views/ParticipantDraftSu
const { Text, Link } = Typography;
const OVERVIEW_CARD_ID = "order-overview";
const STATS_OPEN_KEY = "lunchtime_live_picks_open";
type SubmissionValues = {
choices: NormalizedOrderSelections;
@@ -343,6 +345,12 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
}, [existingSubmission, form, orderId]);
// Live pick counts; polls only while open, keeps the last result (frozen) once closed.
const [statsOpen, setStatsOpen] = useState(() => getStoredValue(STATS_OPEN_KEY) !== "0");
const toggleStats = () =>
setStatsOpen((open) => {
setStoredValue(STATS_OPEN_KEY, open ? "0" : "1");
return !open;
});
const [stats, setStats] = useState<OrderStats | null>(null);
useEffect(() => {
if (isClosed) {
@@ -522,27 +530,6 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
onTabChange={setOverviewTab}
/>
{stats && stats.participant_count > 0 && (
<Card title={`Live picks · ${stats.participant_count} ${stats.participant_count === 1 ? "person" : "people"}`}>
{stats.categories.map((category) => (
<div key={category.label} style={{ marginBottom: 12 }}>
<Text strong>{category.label}</Text>
{category.items.slice(0, 8).map((item) => (
<Flex key={item.name} gap={8} align="center">
<Text ellipsis style={{ flex: "0 0 40%" }}>{item.name}</Text>
<Progress
style={{ flex: 1, margin: 0 }}
percent={(item.count / stats.participant_count) * 100}
format={() => item.count}
size="small"
/>
</Flex>
))}
</div>
))}
</Card>
)}
<Card
title="Your order"
>
@@ -642,6 +629,38 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
</Space>
</Card>
{stats && stats.participant_count > 0 && (
<Card
title="Live picks"
extra={
<Button type="text" size="small" onClick={toggleStats} aria-expanded={statsOpen}>
{statsOpen ? "Hide" : "Show"}
</Button>
}
styles={statsOpen ? undefined : { body: { display: "none" } }}
>
<Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
{stats.participant_count} {stats.participant_count === 1 ? "person has" : "people have"} ordered
</Text>
{stats.categories.map((category) => (
<div key={category.label} style={{ marginBottom: 12 }}>
<Text strong>{category.label}</Text>
{category.items.slice(0, 8).map((item) => (
<Flex key={item.name} gap={8} align="center">
<Text ellipsis style={{ flex: "0 0 30%" }}>{item.name}</Text>
<Progress
style={{ flex: 1, margin: 0 }}
percent={(item.count / stats.participant_count) * 100}
format={() => item.count}
size="small"
/>
</Flex>
))}
</div>
))}
</Card>
)}
</Space>
</div>
</AsyncContent>