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:
1 parent
fedf5a45d3
commit
a348b759a1
3 files changed
+88
-22
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user