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 });
|
const result = await api("/api/admin/users", { userId: admin.userId });
|
||||||
expect(result.users.length).toBeGreaterThan(0);
|
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 { 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 }) => {
|
test("home lists the user's orders", async ({ userPage, user }) => {
|
||||||
await createOrder(user.userId, { title: "Listed on home" });
|
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();
|
await expect(page.getByText("Lunchtime").first()).toBeVisible();
|
||||||
expect(errors).toEqual([]);
|
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 { useApiRequest } from "../hooks/useApiRequest";
|
||||||
import { isNotFoundError } from "../lib/api";
|
import { isNotFoundError } from "../lib/api";
|
||||||
import { navigateTo } from "../lib/routing";
|
import { navigateTo } from "../lib/routing";
|
||||||
|
import { getStoredValue, setStoredValue } from "../lib/storage";
|
||||||
import { apiService } from "../lib/services";
|
import { apiService } from "../lib/services";
|
||||||
import type { OrderStats } from "../lib/services/ordersService";
|
import type { OrderStats } from "../lib/services/ordersService";
|
||||||
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
|
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
|
||||||
@@ -44,6 +45,7 @@ import ParticipantDraftSummaryAlert from "../components/views/ParticipantDraftSu
|
|||||||
const { Text, Link } = Typography;
|
const { Text, Link } = Typography;
|
||||||
|
|
||||||
const OVERVIEW_CARD_ID = "order-overview";
|
const OVERVIEW_CARD_ID = "order-overview";
|
||||||
|
const STATS_OPEN_KEY = "lunchtime_live_picks_open";
|
||||||
|
|
||||||
type SubmissionValues = {
|
type SubmissionValues = {
|
||||||
choices: NormalizedOrderSelections;
|
choices: NormalizedOrderSelections;
|
||||||
@@ -343,6 +345,12 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
|||||||
}, [existingSubmission, form, orderId]);
|
}, [existingSubmission, form, orderId]);
|
||||||
|
|
||||||
// Live pick counts; polls only while open, keeps the last result (frozen) once closed.
|
// 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);
|
const [stats, setStats] = useState<OrderStats | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
@@ -522,27 +530,6 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
|||||||
onTabChange={setOverviewTab}
|
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
|
<Card
|
||||||
title="Your order"
|
title="Your order"
|
||||||
>
|
>
|
||||||
@@ -642,6 +629,38 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
|||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</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>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</AsyncContent>
|
</AsyncContent>
|
||||||
|
|||||||
Reference in new issue
Block a user