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:
1 parent
cfe145f94f
commit
fedf5a45d3
6 files changed
+169
-4
No files matched your search
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user