diff --git a/backend/main.py b/backend/main.py index d6a64c9..8202f63 100644 --- a/backend/main.py +++ b/backend/main.py @@ -812,3 +812,7 @@ def _bwd_hook(_module, _grad_in, grad_out): "class_index": pred_class, "mode": "real", } +# ── VENDOR TRUST SCORE (Issue #45) ────────────────────────────────────────── +from vendors import router as vendors_router, register_routes +register_routes(vendors_router, _db) +app.include_router(vendors_router) diff --git a/backend/migrations/add_vendor_trust_score.sql b/backend/migrations/add_vendor_trust_score.sql new file mode 100644 index 0000000..50364d4 --- /dev/null +++ b/backend/migrations/add_vendor_trust_score.sql @@ -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; + +-- Index for fast leaderboard sorting +CREATE INDEX IF NOT EXISTS idx_vendors_avg_freshness + ON vendors (avg_freshness_score DESC); \ No newline at end of file diff --git a/backend/vendors.py b/backend/vendors.py new file mode 100644 index 0000000..e45c4ec --- /dev/null +++ b/backend/vendors.py @@ -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")] + 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): + """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") + 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)) \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 651f57e..ebc9e87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,18 +8,19 @@ import ScannerPage from './pages/ScannerPage'; import AnalysisDashboard from './pages/AnalysisDashboard'; import MarketMapPage from './pages/MarketMapPage'; import ResultsPage from './pages/ResultsPage'; +import Leaderboard from './pages/Leaderboard'; import PostHogPageView from './components/PostHogPageView'; -import NotFound from './pages/NotFound'; // Importing the new 404 component +import NotFound from './pages/NotFound'; export default function App() { return ( {/* Toast provider for global error notifications */} - + {/* Fires a $pageview event to PostHog on every SPA route change */} - + }> } /> @@ -29,7 +30,8 @@ export default function App() { } /> } /> } /> - + } /> + {/* Catch-all route for broken links/404s */} } /> diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx new file mode 100644 index 0000000..dfe2aff --- /dev/null +++ b/src/pages/Leaderboard.tsx @@ -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 = { + gold: { emoji: "🥇", label: "Gold", color: "#f59e0b" }, + silver: { emoji: "🥈", label: "Silver", color: "#9ca3af" }, + bronze: { emoji: "🥉", label: "Bronze", color: "#f97316" }, + unranked: { emoji: "⚪", label: "Unranked", color: "#d1d5db" }, +}; + +const TREND: Record = { + up: { icon: "↑", color: "#22c55e", label: "Improving" }, + down: { icon: "↓", color: "#ef4444", label: "Declining" }, + stable: { icon: "→", color: "#9ca3af", label: "Stable" }, +}; + +export default function Leaderboard() { + const [vendors, setVendors] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/v1/vendors/leaderboard") + .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 ( +
+ Loading leaderboard... +
+ ); + + if (error) return ( +
+ {error} +
+ ); + + return ( +
+

🐟 Vendor Trust Leaderboard

+

+ Rankings based on anonymous freshness scans across markets. +

+ + {vendors.length === 0 ? ( +

No vendor data yet.

+ ) : ( +
+ {vendors.map((vendor, index) => { + const badge = BADGE[vendor.trust_badge ?? "unranked"]; + const trend = TREND[vendor.trend ?? "stable"]; + return ( +
+ {/* Rank */} + + {index + 1} + + + {/* Badge */} + + {badge.emoji} + + + {/* Info */} +
+

{vendor.name}

+

{vendor.address}

+
+ + {/* Score */} +
+

+ {(vendor.avg_freshness_score ?? 0).toFixed(1)} + /100 +

+

{vendor.total_scans ?? 0} scans

+
+ + {/* Trend */} + + {trend.icon} + +
+ ); + })} +
+ )} +
+ ); +} \ No newline at end of file