Closed style for live picks, remember expanded state, creator email in home orders list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4f708e3ff
commit
496acfd87a
7 files changed
+58
-26
No files matched your search
@@ -167,6 +167,7 @@ def row_to_my_order(row: sqlite3.Row) -> dict:
|
||||
"image_url": row["image_url"],
|
||||
"closed": bool(row["closed"]),
|
||||
"created_at": row["created_at"],
|
||||
"creator_email": row["creator_email"],
|
||||
"is_owner": bool(row["admin_token"]),
|
||||
"is_participant": bool(row["submission_token"]),
|
||||
"submission": {
|
||||
|
||||
@@ -35,7 +35,7 @@ from .services.account_service import (
|
||||
upsert_user_profile_email,
|
||||
)
|
||||
from .services.common_service import clean_image_url, clean_user_email, clean_user_id, ensure_email_not_privileged, is_site_admin, now_iso
|
||||
from .services.stats_service import get_managed_stats, get_my_stats, get_order_stats, get_site_stats
|
||||
from .services.stats_service import OWNER_EMAIL_SQL, get_managed_stats, get_my_stats, get_order_stats, get_site_stats
|
||||
from .services.backup_service import start_backup_scheduler
|
||||
from .services.config_service import get_email_settings, load_config
|
||||
from .services.email_service import (
|
||||
@@ -904,6 +904,7 @@ def get_my_orders(
|
||||
go.closed,
|
||||
go.created_at,
|
||||
go.admin_token AS order_admin_token,
|
||||
{OWNER_EMAIL_SQL} AS creator_email,
|
||||
uot.admin_token,
|
||||
uot.submission_token,
|
||||
s.choices_json,
|
||||
|
||||
@@ -52,7 +52,7 @@ test("live picks: counts, podium stamp, hide/show, frozen after close", async ({
|
||||
await loginAs(page, first);
|
||||
await page.goto(`/order/${order.id}`);
|
||||
|
||||
const card = page.locator(".ant-card", { hasText: "Live picks" }).filter({ hasText: "ordered" });
|
||||
const card = page.locator(".ant-card", { hasText: /(Live|Final) 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();
|
||||
@@ -88,3 +88,27 @@ test("live picks on phones: banner opens a sheet, dismiss leaves a bolt", async
|
||||
await expect(page.getByRole("status")).toBeVisible();
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("live picks on phones: closed order gets the final banner, expanded state survives reload", async ({ browser, user }) => {
|
||||
const order = await createOrder(user.userId, { title: "Closed picks" });
|
||||
const first = await signIn();
|
||||
await submit(first.userId, order.id, { burger: "Veggie" });
|
||||
await api(`/api/orders/${order.id}/admin/status`, { userId: user.userId, method: "PUT", body: { closed: true } });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 800 } });
|
||||
await loginAs(page, first);
|
||||
await page.goto(`/order/${order.id}`);
|
||||
|
||||
const banner = page.getByRole("status");
|
||||
await expect(banner).toContainText("Final: 1 ordered · Veggie led (1)");
|
||||
await expect(banner).toHaveClass(/closed/);
|
||||
await page.getByRole("button", { name: /Final:/ }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByText("1 person has ordered")).toBeVisible();
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("home list shows creator email, state and date", async ({ userPage, user }) => {
|
||||
await createOrder(user.userId, { title: "Creator on home" });
|
||||
await userPage.goto("/");
|
||||
await expect(userPage.getByText(new RegExp(`Open • ${user.email} • `))).toBeVisible();
|
||||
});
|
||||
@@ -121,6 +121,7 @@ export default function HomeOrdersTable({
|
||||
{closedStr}
|
||||
</Tooltip>
|
||||
}
|
||||
{item.creator_email ? ` • ${item.creator_email}` : ""}
|
||||
{" • "}
|
||||
{
|
||||
<Tooltip title={`Created at: ${dateStr}`}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { CloseOutlined, ThunderboltFilled } from "@ant-design/icons";
|
||||
import { CloseOutlined, LockOutlined, ThunderboltFilled } from "@ant-design/icons";
|
||||
import { Button, Card, Flex, Progress, Typography } from "antd";
|
||||
import type { OrderStats } from "../../lib/services/ordersService";
|
||||
import { getStoredValue, setStoredValue } from "../../lib/storage";
|
||||
@@ -7,13 +7,15 @@ import { getStoredValue, setStoredValue } from "../../lib/storage";
|
||||
const { Text } = Typography;
|
||||
const HIDDEN_KEY = "lunchtime_live_picks_hidden";
|
||||
|
||||
function useHidden() {
|
||||
const [hidden, setHidden] = useState(() => getStoredValue(HIDDEN_KEY) === "1");
|
||||
const EXPANDED_KEY = "lunchtime_live_picks_expanded";
|
||||
|
||||
function useStoredFlag(key: string) {
|
||||
const [flag, setFlag] = useState(() => getStoredValue(key) === "1");
|
||||
return [
|
||||
hidden,
|
||||
flag,
|
||||
(value: boolean) => {
|
||||
setStoredValue(HIDDEN_KEY, value ? "1" : "0");
|
||||
setHidden(value);
|
||||
setStoredValue(key, value ? "1" : "0");
|
||||
setFlag(value);
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -48,8 +50,8 @@ function Bars({ stats }: { stats: OrderStats }) {
|
||||
}
|
||||
|
||||
/** Desktop: quiet side panel that can be tucked away. */
|
||||
export function LivePicksPanel({ stats }: { stats: OrderStats }) {
|
||||
const [hidden, setHidden] = useHidden();
|
||||
export function LivePicksPanel({ stats, closed }: { stats: OrderStats; closed: boolean }) {
|
||||
const [hidden, setHidden] = useStoredFlag(HIDDEN_KEY);
|
||||
if (hidden) {
|
||||
return (
|
||||
<Button type="text" size="small" icon={<ThunderboltFilled />} onClick={() => setHidden(false)}>
|
||||
@@ -62,8 +64,8 @@ export function LivePicksPanel({ stats }: { stats: OrderStats }) {
|
||||
size="small"
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<span className="live-dot" aria-hidden />
|
||||
Live picks
|
||||
<span className={closed ? "live-dot closed" : "live-dot"} aria-hidden />
|
||||
{closed ? "Final picks" : "Live picks"}
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
@@ -78,15 +80,15 @@ export function LivePicksPanel({ stats }: { stats: OrderStats }) {
|
||||
}
|
||||
|
||||
/** Phones: floating energy banner that opens a sheet with the bars; dismiss leaves a small bolt to bring it back. */
|
||||
export function LivePicksBanner({ stats }: { stats: OrderStats }) {
|
||||
const [hidden, setHidden] = useHidden();
|
||||
const [open, setOpen] = useState(false);
|
||||
export function LivePicksBanner({ stats, closed }: { stats: OrderStats; closed: boolean }) {
|
||||
const [hidden, setHidden] = useStoredFlag(HIDDEN_KEY);
|
||||
const [open, setOpen] = useStoredFlag(EXPANDED_KEY);
|
||||
const top = stats.categories[0]?.items[0];
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<button className="live-fab" aria-label="Show live picks" onClick={() => setHidden(false)}>
|
||||
<ThunderboltFilled />
|
||||
<button className={closed ? "live-fab closed" : "live-fab"} aria-label="Show live picks" onClick={() => setHidden(false)}>
|
||||
{closed ? <LockOutlined /> : <ThunderboltFilled />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -97,11 +99,12 @@ export function LivePicksBanner({ stats }: { stats: OrderStats }) {
|
||||
<Bars stats={stats} />
|
||||
</Card>
|
||||
)}
|
||||
<div className="live-banner" role="status">
|
||||
<div className={closed ? "live-banner closed" : "live-banner"} role="status">
|
||||
<button className="live-banner-main" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
<ThunderboltFilled />
|
||||
{closed ? <LockOutlined /> : <ThunderboltFilled />}
|
||||
<span>
|
||||
{stats.participant_count} ordered{top ? ` · ${top.name} leads (${top.count})` : ""}
|
||||
{closed ? "Final: " : ""}
|
||||
{stats.participant_count} ordered{top ? ` · ${top.name} ${closed ? "led" : "leads"} (${top.count})` : ""}
|
||||
</span>
|
||||
</button>
|
||||
<button className="live-banner-close" aria-label="Hide live picks" onClick={() => setHidden(true)}>
|
||||
|
||||
@@ -229,6 +229,10 @@
|
||||
background: #ea580c;
|
||||
box-shadow: 0 6px 18px rgba(194, 65, 12, 0.45);
|
||||
}
|
||||
.live-dot.closed { background: #8c8c8c; animation: none; }
|
||||
.live-banner.closed { background: #6b6560; animation: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); }
|
||||
.live-banner.closed .anticon { animation: none; }
|
||||
.live-fab.closed { background: #6b6560; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-dot, .live-banner, .live-banner-main .anticon { animation: none; }
|
||||
}
|
||||
@@ -346,9 +346,6 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
// Live pick counts; polls only while open, keeps the last result (frozen) once closed.
|
||||
const [stats, setStats] = useState<OrderStats | null>(null);
|
||||
useEffect(() => {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
let mounted = true;
|
||||
const refreshStats = () =>
|
||||
apiService.orders
|
||||
@@ -356,7 +353,8 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
.then((latest) => mounted && setStats(latest))
|
||||
.catch(() => {});
|
||||
refreshStats();
|
||||
const intervalId = setInterval(refreshStats, 5000);
|
||||
// Closed orders are fetched once (final numbers), open ones keep polling.
|
||||
const intervalId = isClosed ? undefined : setInterval(refreshStats, 5000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(intervalId);
|
||||
@@ -629,11 +627,11 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
</Card>
|
||||
{showPanel && (
|
||||
<aside className="live-picks-aside">
|
||||
<LivePicksPanel stats={stats} />
|
||||
<LivePicksPanel stats={stats} closed={isClosed} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
{showBanner && <LivePicksBanner stats={stats} />}
|
||||
{showBanner && <LivePicksBanner stats={stats} closed={isClosed} />}
|
||||
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user