-
Notifications
You must be signed in to change notification settings - Fork 19
feat: add vendor trust score endpoint and leaderboard UI (closes #45) #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| -- Migration: Vendor Trust Score (Issue #45) | ||
| -- Run this in your Supabase SQL Editor | ||
|
|
||
| ALTER TABLE vendors | ||
| ADD COLUMN IF NOT EXISTS trust_badge TEXT | ||
| CHECK (trust_badge IN ('gold', 'silver', 'bronze', 'unranked')) | ||
| DEFAULT 'unranked', | ||
| ADD COLUMN IF NOT EXISTS trend TEXT | ||
| CHECK (trend IN ('up', 'down', 'stable')) | ||
| DEFAULT 'stable', | ||
| ADD COLUMN IF NOT EXISTS total_scans INTEGER DEFAULT 0, | ||
| ADD COLUMN IF NOT EXISTS avg_freshness_score NUMERIC(4,2) DEFAULT 0.0; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
|
|
||
| -- Index for fast leaderboard sorting | ||
| CREATE INDEX IF NOT EXISTS idx_vendors_avg_freshness | ||
| ON vendors (avg_freshness_score DESC); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| from fastapi import APIRouter, HTTPException | ||
| from datetime import datetime, timedelta, timezone | ||
| from typing import Optional | ||
|
|
||
| router = APIRouter(prefix="/api/v1/vendors", tags=["vendors"]) | ||
|
|
||
|
|
||
| def _compute_badge(avg_score: float, total_scans: int) -> str: | ||
| if total_scans < 5: | ||
| return "unranked" | ||
| if avg_score >= 80: | ||
| return "gold" | ||
| if avg_score >= 60: | ||
| return "silver" | ||
| if avg_score >= 40: | ||
| return "bronze" | ||
| return "unranked" | ||
|
|
||
|
|
||
| def _compute_trend(db, vendor_id: str) -> str: | ||
| now = datetime.now(timezone.utc) | ||
| week_ago = (now - timedelta(days=7)).isoformat() | ||
| two_weeks_ago = (now - timedelta(days=14)).isoformat() | ||
|
|
||
| recent = db.table("scans").select("freshness_index") \ | ||
| .eq("vendor_id", vendor_id) \ | ||
| .gte("timestamp", week_ago).execute() | ||
|
|
||
| prior = db.table("scans").select("freshness_index") \ | ||
| .eq("vendor_id", vendor_id) \ | ||
| .gte("timestamp", two_weeks_ago) \ | ||
| .lt("timestamp", week_ago).execute() | ||
|
|
||
| def avg(rows): | ||
| vals = [r["freshness_index"] for r in rows if r.get("freshness_index")] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Trend calculation incorrectly ignores valid Prompt for AI agents |
||
| return sum(vals) / len(vals) if vals else None | ||
|
|
||
| r_avg = avg(recent.data or []) | ||
| p_avg = avg(prior.data or []) | ||
|
|
||
| if r_avg is None or p_avg is None: | ||
| return "stable" | ||
| if r_avg > p_avg + 3: | ||
| return "up" | ||
| if r_avg < p_avg - 3: | ||
| return "down" | ||
| return "stable" | ||
|
|
||
|
|
||
| def register_routes(router: APIRouter, db_getter): | ||
| """ | ||
| Called from main.py to register all vendor trust routes. | ||
| db_getter is the _db function already defined in main.py. | ||
| """ | ||
|
|
||
| @router.get("/leaderboard") | ||
| async def get_leaderboard(limit: int = 20): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Missing range validation on Prompt for AI agents |
||
| """Public leaderboard — no auth required.""" | ||
| try: | ||
| resp = ( | ||
| db_getter().table("vendors") | ||
| .select( | ||
| "id, name, address, avg_freshness_score, " | ||
| "total_scans, trust_badge, trend" | ||
| ) | ||
| .order("avg_freshness_score", desc=True) | ||
| .limit(limit) | ||
| .execute() | ||
| ) | ||
| return {"success": True, "leaderboard": resp.data or []} | ||
| except Exception as exc: | ||
| raise HTTPException(status_code=500, detail=str(exc)) | ||
|
|
||
| @router.get("/{vendor_id}/trust-score") | ||
| async def get_vendor_trust_score(vendor_id: str): | ||
| """Trust score for a single vendor — no auth required.""" | ||
| try: | ||
| resp = ( | ||
| db_getter().table("vendors") | ||
| .select( | ||
| "id, name, address, avg_freshness_score, " | ||
| "total_scans, trust_badge, trend" | ||
| ) | ||
| .eq("id", vendor_id) | ||
| .limit(1) | ||
| .execute() | ||
| ) | ||
| if not resp.data: | ||
| raise HTTPException(status_code=404, detail="Vendor not found.") | ||
| vendor = resp.data[0] | ||
| trend = _compute_trend(db_getter(), vendor_id) | ||
| vendor["trend"] = trend | ||
| return {"success": True, "vendor": vendor} | ||
| except HTTPException: | ||
| raise | ||
| except Exception as exc: | ||
| raise HTTPException(status_code=500, detail=str(exc)) | ||
|
|
||
| @router.post("/{vendor_id}/recalculate") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: State-changing POST Prompt for AI agents |
||
| async def recalculate_trust_score(vendor_id: str): | ||
| """ | ||
| Recompute avg_freshness_score, total_scans, trust_badge, trend | ||
| from the scans table. Call this after a new scan is submitted. | ||
| """ | ||
| try: | ||
| scans = ( | ||
| db_getter().table("scans") | ||
| .select("freshness_index") | ||
| .eq("vendor_id", vendor_id) | ||
| .execute() | ||
| ) | ||
| rows = [r for r in (scans.data or []) if r.get("freshness_index") is not None] | ||
| if not rows: | ||
| raise HTTPException( | ||
| status_code=404, | ||
| detail="No scans found for this vendor." | ||
| ) | ||
|
|
||
| scores = [r["freshness_index"] for r in rows] | ||
| total = len(scores) | ||
| avg = round(sum(scores) / total, 2) | ||
| badge = _compute_badge(avg, total) | ||
| trend = _compute_trend(db_getter(), vendor_id) | ||
|
|
||
| db_getter().table("vendors").update({ | ||
| "avg_freshness_score": avg, | ||
| "total_scans": total, | ||
| "trust_badge": badge, | ||
| "trend": trend, | ||
| }).eq("id", vendor_id).execute() | ||
|
|
||
| return { | ||
| "success": True, | ||
| "vendor_id": vendor_id, | ||
| "avg_score": avg, | ||
| "total_scans": total, | ||
| "trust_badge": badge, | ||
| "trend": trend, | ||
| } | ||
| except HTTPException: | ||
| raise | ||
| except Exception as exc: | ||
| raise HTTPException(status_code=500, detail=str(exc)) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { useEffect, useState } from "react"; | ||
|
|
||
| type Badge = "gold" | "silver" | "bronze" | "unranked"; | ||
| type Trend = "up" | "down" | "stable"; | ||
|
|
||
| interface Vendor { | ||
| id: string; | ||
| name: string; | ||
| address: string; | ||
| avg_freshness_score: number; | ||
| total_scans: number; | ||
| trust_badge: Badge; | ||
| trend: Trend; | ||
| } | ||
|
|
||
| const BADGE: Record<Badge, { emoji: string; label: string; color: string }> = { | ||
| gold: { emoji: "🥇", label: "Gold", color: "#f59e0b" }, | ||
| silver: { emoji: "🥈", label: "Silver", color: "#9ca3af" }, | ||
| bronze: { emoji: "🥉", label: "Bronze", color: "#f97316" }, | ||
| unranked: { emoji: "⚪", label: "Unranked", color: "#d1d5db" }, | ||
| }; | ||
|
|
||
|
Comment on lines
+16
to
+22
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NO EMOJIS: This project strictly avoids emojis to maintain a professional, academic/cyberpunk aesthetic. Please remove the 🥇🥈🥉⚪ emojis. Instead, use mono-spaced text labels like [ GOLD ] or use custom SVG icons. |
||
| const TREND: Record<Trend, { icon: string; color: string; label: string }> = { | ||
| up: { icon: "↑", color: "#22c55e", label: "Improving" }, | ||
|
Comment on lines
+22
to
+24
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded Colors: You used classes like bg-white, text-gray-800, and text-blue-600. These completely break our Dark/Light theme toggle. You must use our CSS variables. For example, replace bg-white with bg-surface-low or use the existing component. Replace text colors with text-on-surface or text-neon. |
||
| down: { icon: "↓", color: "#ef4444", label: "Declining" }, | ||
| stable: { icon: "→", color: "#9ca3af", label: "Stable" }, | ||
| }; | ||
|
|
||
| export default function Leaderboard() { | ||
| const [vendors, setVendors] = useState<Vendor[]>([]); | ||
| const [loading, setLoading] = useState(true); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| fetch("/api/v1/vendors/leaderboard") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: This fetch bypasses the configured API base URL, so leaderboard requests can fail in deployed environments where backend and frontend are on different origins. Prompt for AI agents |
||
| .then((r) => { | ||
| if (!r.ok) throw new Error("Failed to fetch leaderboard."); | ||
| return r.json(); | ||
| }) | ||
| .then((data) => setVendors(data.leaderboard || [])) | ||
| .catch((e) => setError(e.message)) | ||
| .finally(() => setLoading(false)); | ||
| }, []); | ||
|
|
||
| if (loading) return ( | ||
| <div className="flex items-center justify-center min-h-screen text-gray-500"> | ||
| Loading leaderboard... | ||
| </div> | ||
| ); | ||
|
|
||
| if (error) return ( | ||
| <div className="flex items-center justify-center min-h-screen text-red-500"> | ||
| {error} | ||
| </div> | ||
| ); | ||
|
|
||
| return ( | ||
| <div className="max-w-2xl mx-auto px-4 py-10"> | ||
| <h1 className="text-3xl font-bold mb-1">🐟 Vendor Trust Leaderboard</h1> | ||
| <p className="text-gray-500 mb-8 text-sm"> | ||
| Rankings based on anonymous freshness scans across markets. | ||
| </p> | ||
|
Comment on lines
+58
to
+62
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please utilize our custom fonts. Headers should use font-[family-name:var(--font-display)] and metrics/small text should use font-[family-name:var(--font-mono)] tracking-widest to match the terminal-like appearance. |
||
|
|
||
| {vendors.length === 0 ? ( | ||
| <p className="text-gray-400 text-center py-20">No vendor data yet.</p> | ||
| ) : ( | ||
| <div className="space-y-3"> | ||
| {vendors.map((vendor, index) => { | ||
| const badge = BADGE[vendor.trust_badge ?? "unranked"]; | ||
| const trend = TREND[vendor.trend ?? "stable"]; | ||
| return ( | ||
| <div | ||
| key={vendor.id} | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Brutalist Aesthetic: Our app avoids soft, rounded UI elements. Please remove rounded-xl and shadow-sm and stick to the sharp borders (border-outline-variant/30) used in the rest of the application. |
||
| className="flex items-center gap-4 p-4 rounded-xl border border-gray-200 bg-white shadow-sm" | ||
| > | ||
| {/* Rank */} | ||
| <span className="w-7 text-center text-lg font-bold text-gray-300"> | ||
| {index + 1} | ||
| </span> | ||
|
|
||
| {/* Badge */} | ||
| <span | ||
| className="text-2xl" | ||
| title={badge.label} | ||
| style={{ color: badge.color }} | ||
| > | ||
| {badge.emoji} | ||
| </span> | ||
|
|
||
| {/* Info */} | ||
| <div className="flex-1 min-w-0"> | ||
| <p className="font-semibold text-gray-800 truncate">{vendor.name}</p> | ||
| <p className="text-xs text-gray-400 truncate">{vendor.address}</p> | ||
| </div> | ||
|
|
||
| {/* Score */} | ||
| <div className="text-right shrink-0"> | ||
| <p className="text-lg font-bold text-blue-600"> | ||
| {(vendor.avg_freshness_score ?? 0).toFixed(1)} | ||
| <span className="text-xs font-normal text-gray-400">/100</span> | ||
| </p> | ||
| <p className="text-xs text-gray-400">{vendor.total_scans ?? 0} scans</p> | ||
| </div> | ||
|
|
||
| {/* Trend */} | ||
| <span | ||
| className="text-xl font-bold shrink-0" | ||
| title={trend.label} | ||
| style={{ color: trend.color }} | ||
| > | ||
| {trend.icon} | ||
| </span> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1:
avg_freshness_score NUMERIC(4,2)cannot store the value100.00, which is possible when a vendor's average freshness reaches the maximum of the 0–100 percentage scale. This will cause numeric overflow errors at runtime.Prompt for AI agents