Add live pick stats and podium stamps on the participant order screen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
simandClaude Sonnet 5.5 committed 2026-11-06 13:36:57 +01:00
1 parent cfe145f94f
commit fedf5a45d3
6 files changed
+169 -4

No files matched your search

+4
View File
@@ -85,6 +85,10 @@ def init_db() -> None:
submission_columns = {row["name"] for row in conn.execute("PRAGMA table_info(submissions)")}
if "handed_out" not in submission_columns:
conn.execute("ALTER TABLE submissions ADD COLUMN handed_out INTEGER NOT NULL DEFAULT 0")
if "ranked_at" not in submission_columns:
# Podium ranking key: set on the participant's own create/edit, unlike updated_at which admin status changes bump.
conn.execute("ALTER TABLE submissions ADD COLUMN ranked_at TEXT")
conn.execute("UPDATE submissions SET ranked_at = created_at")
profile_columns = {row["name"] for row in conn.execute("PRAGMA table_info(user_profiles)")}
if "created_at" not in profile_columns:
+13 -4
View File
@@ -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_site_stats
from .services.stats_service import 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 (
@@ -979,6 +979,13 @@ def get_order(order_id: str) -> dict[str, Any]:
}
@app.get("/api/orders/{order_id}/stats")
def get_order_stats_route(order_id: str, user_id: str | None = Header(default=None, alias="X-User-Id")) -> dict[str, Any]:
order = ensure_order_exists(order_id)
with get_connection() as conn:
return get_order_stats(conn, order_id, load_order_config(order["admin_token"]), user_id)
@app.delete("/api/orders/{order_id}")
def delete_order(order_id: str, user_id: str = Depends(get_existing_user_id)) -> dict[str, bool]:
clean_id = clean_user_id(user_id)
@@ -1063,8 +1070,8 @@ def create_submission(
conn.execute(
"""
INSERT INTO submissions (
id, group_order_id, submission_token, email, choices_json, accepted, paid, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
id, group_order_id, submission_token, email, choices_json, accepted, paid, created_at, updated_at, ranked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
submission_id,
@@ -1076,6 +1083,7 @@ def create_submission(
0,
timestamp,
timestamp,
timestamp,
),
)
upsert_user_order_tokens(conn, clean_id, order_id, submission_token=submission_token)
@@ -1150,7 +1158,7 @@ def update_submission(
conn.execute(
"""
UPDATE submissions
SET email = ?, choices_json = ?, accepted = ?, updated_at = ?
SET email = ?, choices_json = ?, accepted = ?, updated_at = ?, ranked_at = ?
WHERE group_order_id = ? AND submission_token = ?
""",
(
@@ -1158,6 +1166,7 @@ def update_submission(
json.dumps(normalized_choices, ensure_ascii=False),
0,
updated_at,
updated_at,
order_id,
token,
),
+37
View File
@@ -6,6 +6,7 @@ from collections import Counter
from datetime import datetime, timedelta, timezone
from typing import Any
from .account_service import get_order_creator_info, get_user_order_tokens
from .common_service import now_iso
from .order_file_service import (
CONFIGS_DIR,
@@ -367,3 +368,39 @@ def get_site_stats(conn: sqlite3.Connection) -> dict[str, Any]:
"items": [{"category": cat, "name": n, "count": c} for (cat, n), c in item_counts.most_common(300)],
"storage": storage,
}
def get_order_stats(conn: sqlite3.Connection, order_id: str, config: dict[str, Any], user_id: str | None) -> dict[str, Any]:
"""Anonymous live pick counts per category, plus the caller's own podium rank (1-3, creator skipped)."""
rows = conn.execute(
"SELECT submission_token, choices_json FROM submissions WHERE group_order_id = ? ORDER BY ranked_at, id",
(order_id,),
).fetchall()
counts: dict[str, Counter] = {}
for row in rows:
try:
choices = json.loads(row["choices_json"] or "{}")
except ValueError:
continue
for item in _selected_items(choices, config):
counts.setdefault(item["category"], Counter())[item["name"]] += 1
creator_id, _ = get_order_creator_info(conn, order_id)
creator_tokens = {
r["submission_token"]
for r in conn.execute(
"SELECT submission_token FROM user_order_tokens WHERE group_order_id = ? AND user_id = ?",
(order_id, creator_id),
)
}
ranked = [r["submission_token"] for r in rows if r["submission_token"] not in creator_tokens]
mine = get_user_order_tokens(conn, user_id, order_id) if user_id else None
token = mine["submission_token"] if mine else None
rank = ranked.index(token) + 1 if token in ranked else None
return {
"participant_count": len(rows),
"categories": [
{"label": label, "items": [{"name": n, "count": c} for n, c in cnt.most_common()]}
for label, cnt in counts.items()
],
"my_rank": rank if rank and rank <= 3 else None,
}
+58
View File
@@ -0,0 +1,58 @@
"""Run from src/backend: python -m unittest discover tests"""
import json
import os
import tempfile
import unittest
os.environ["DB_PATH"] = tempfile.mkdtemp() # app.db creates its data dir on import
from app.db import get_connection, init_db
from app.services.account_service import upsert_user_order_tokens
from app.services.stats_service import get_order_stats
CONFIG = {"categories": [{"id": "main", "label": "Main"}]}
class OrderStatsTest(unittest.TestCase):
def setUp(self):
init_db()
self.conn = get_connection()
self.addCleanup(self.conn.close)
self.conn.execute("DELETE FROM group_orders")
self.conn.execute("DELETE FROM user_order_tokens")
self.conn.execute(
"INSERT INTO group_orders (id, admin_token, title, description, created_at) VALUES ('o', 'adm', 't', '', 'x')"
)
upsert_user_order_tokens(self.conn, "creator", "o", admin_token="adm")
def submit(self, user, at, dish="Burger"):
token = f"tok-{user}"
self.conn.execute(
"INSERT INTO submissions (id, group_order_id, submission_token, email, choices_json, created_at, updated_at, ranked_at)"
" VALUES (?, 'o', ?, '', ?, ?, ?, ?)",
(user, token, json.dumps({"main": dish}), at, at, at),
)
upsert_user_order_tokens(self.conn, user, "o", submission_token=token)
def rank(self, user):
return get_order_stats(self.conn, "o", CONFIG, user)["my_rank"]
def test_ranking_and_counts(self):
self.submit("creator", "1")
self.submit("a", "2")
self.submit("b", "3", "Pizza")
self.submit("c", "4")
self.submit("d", "5")
self.assertEqual([self.rank(u) for u in ("creator", "a", "b", "c", "d", None)], [None, 1, 2, 3, None, None])
stats = get_order_stats(self.conn, "o", CONFIG, None)
self.assertEqual(stats["participant_count"], 5)
self.assertEqual(stats["categories"][0]["items"][0], {"name": "Burger", "count": 4})
# edit moves you back; deleting promotes others
self.conn.execute("UPDATE submissions SET ranked_at = '9' WHERE id = 'a'")
self.assertEqual([self.rank(u) for u in ("a", "b", "c", "d")], [None, 1, 2, 3])
self.conn.execute("DELETE FROM submissions WHERE id = 'b'")
self.assertEqual(self.rank("d"), 2)
if __name__ == "__main__":
unittest.main()
@@ -11,6 +11,12 @@ import type {
UploadImageResponse,
} from "../types";
export type OrderStats = {
participant_count: number;
categories: { label: string; items: { name: string; count: number }[] }[];
my_rank: 1 | 2 | 3 | null;
};
type CreateOrderPayload = {
title: string;
description?: string | null;
@@ -96,6 +102,8 @@ export const ordersService = {
body: formData,
});
},
getStats: (orderId: string) =>
apiFetch<OrderStats>(`/api/orders/${orderId}/stats`),
deleteImage: (orderId: string) =>
apiFetch<{ status: string }>(`/api/orders/${orderId}/admin/image`, {
method: "DELETE",
@@ -7,6 +7,7 @@ import {
Flex,
Form,
Popconfirm,
Progress,
Row,
Space,
Typography,
@@ -17,6 +18,7 @@ import { useApiRequest } from "../hooks/useApiRequest";
import { isNotFoundError } from "../lib/api";
import { navigateTo } from "../lib/routing";
import { apiService } from "../lib/services";
import type { OrderStats } from "../lib/services/ordersService";
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
import { PaymentMethodsAccordion } from "../components/orders/PaymentDetailsCard";
@@ -340,6 +342,26 @@ 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 [stats, setStats] = useState<OrderStats | null>(null);
useEffect(() => {
if (isClosed) {
return;
}
let mounted = true;
const refreshStats = () =>
apiService.orders
.getStats(orderId)
.then((latest) => mounted && setStats(latest))
.catch(() => {});
refreshStats();
const intervalId = setInterval(refreshStats, 5000);
return () => {
mounted = false;
clearInterval(intervalId);
};
}, [isClosed, orderId]);
const onFinish = async (values: FormModel) => {
if (isClosed) {
message.warning("This order is closed.");
@@ -500,10 +522,37 @@ 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"
>
<Space direction="vertical" style={{ width: "100%" }}>
{stats?.my_rank && (
<Alert
type="success"
message={`${["🥇", "🥈", "🥉"][stats.my_rank - 1]} You were ${["1st", "2nd", "3rd"][stats.my_rank - 1]} to order!`}
/>
)}
{showSummaryView && (
<ParticipantSubmissionSummary
submission={existingSubmission}