v0.2 - 19 features: comparator, allergies, gamification, shopping list, achievements, stats, profile, share, bottom nav

This commit is contained in:
2026-02-10 18:52:42 -03:00
parent e8f4788a33
commit ecdd7546d3
33 changed files with 2105 additions and 309 deletions

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.models.user import User
from app.models.scan import Scan
from app.models.achievement import Achievement, UserAchievement
from app.utils.security import get_current_user
router = APIRouter(prefix="/api", tags=["achievements"])
@router.get("/achievements")
async def get_achievements(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# Get all achievements
res = await db.execute(select(Achievement).order_by(Achievement.id))
all_achievements = res.scalars().all()
# Get user unlocked
unlocked_res = await db.execute(
select(UserAchievement).where(UserAchievement.user_id == user.id)
)
unlocked = {ua.achievement_id: ua.unlocked_at for ua in unlocked_res.scalars().all()}
# Get progress counts
total_scans_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id))
total_scans = total_scans_res.scalar() or 0
photo_scans_res = await db.execute(
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.barcode == "PHOTO")
)
photo_scans = photo_scans_res.scalar() or 0
progress_map = {
"first_scan": min(total_scans, 1),
"detective": min(total_scans, 10),
"expert": min(total_scans, 50),
"master": min(total_scans, 100),
"photographer": min(photo_scans, 5),
"comparator": 0, # tracked separately
}
result = []
for a in all_achievements:
is_unlocked = a.id in unlocked
result.append({
"id": a.id,
"code": a.code,
"name": a.name,
"description": a.description,
"emoji": a.emoji,
"target": a.target,
"progress": progress_map.get(a.code, 0),
"unlocked": is_unlocked,
"unlocked_at": unlocked[a.id].isoformat() if is_unlocked else None,
})
return {"achievements": result, "total_scans": total_scans}

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import json
from app.database import get_db
from app.models.user import User
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
@@ -8,6 +9,14 @@ from app.utils.security import hash_password, verify_password, create_access_tok
router = APIRouter(prefix="/api/auth", tags=["auth"])
def user_dict(user: User) -> dict:
return {
"id": user.id, "email": user.email, "name": user.name,
"is_premium": user.is_premium,
"allergies": json.loads(user.allergies or "[]"),
"health_profile": user.health_profile or "normal",
}
@router.post("/register", response_model=TokenResponse)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == req.email))
@@ -20,10 +29,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
await db.refresh(user)
token = create_access_token({"sub": str(user.id)})
return TokenResponse(
access_token=token,
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
)
return TokenResponse(access_token=token, user=user_dict(user))
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
@@ -33,11 +39,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=401, detail="Email ou senha incorretos")
token = create_access_token({"sub": str(user.id)})
return TokenResponse(
access_token=token,
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
)
return TokenResponse(access_token=token, user=user_dict(user))
@router.get("/me", response_model=UserResponse)
@router.get("/me")
async def me(user: User = Depends(get_current_user)):
return UserResponse(id=user.id, email=user.email, name=user.name, is_premium=user.is_premium)
return user_dict(user)

View File

@@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import json
from app.database import get_db
from app.models.user import User
from app.models.scan import Scan
from app.models.product import Product
from app.utils.security import get_current_user
from app.services.achievements import check_achievements
router = APIRouter(prefix="/api", tags=["compare"])
@router.post("/compare")
async def compare_products(data: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
scan_ids = data.get("scan_ids", [])
if len(scan_ids) < 2 or len(scan_ids) > 4:
raise HTTPException(status_code=400, detail="Selecione entre 2 e 4 produtos")
results = []
for sid in scan_ids:
res = await db.execute(select(Scan).where(Scan.id == sid, Scan.user_id == user.id))
scan = res.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=404, detail=f"Scan {sid} não encontrado")
analysis = json.loads(scan.analysis_json or '{}')
prod_res = await db.execute(select(Product).where(Product.barcode == scan.barcode))
product = prod_res.scalar_one_or_none()
results.append({
"scan_id": scan.id,
"product_name": scan.product_name,
"brand": scan.brand,
"score": scan.score,
"image_url": product.image_url if product else None,
"nutri_score": product.nutri_score if product else None,
"nova_group": product.nova_group if product else None,
"positives": analysis.get("positives", []),
"negatives": analysis.get("negatives", []),
"nutrition": analysis.get("nutrition", {}),
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
})
# Check comparison achievement
await check_achievements(user.id, db, action="compare")
return {"products": results}

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import List, Optional
import json
from app.database import get_db
from app.models.user import User
from app.utils.security import get_current_user
router = APIRouter(prefix="/api/auth", tags=["profile"])
class ProfileUpdate(BaseModel):
allergies: Optional[List[str]] = None
health_profile: Optional[str] = None
name: Optional[str] = None
VALID_PROFILES = ["normal", "crianca", "gestante", "diabetico", "hipertenso"]
@router.put("/profile")
async def update_profile(data: ProfileUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
if data.allergies is not None:
user.allergies = json.dumps(data.allergies)
if data.health_profile is not None:
if data.health_profile not in VALID_PROFILES:
raise HTTPException(status_code=400, detail=f"Perfil inválido. Use: {', '.join(VALID_PROFILES)}")
user.health_profile = data.health_profile
if data.name is not None:
user.name = data.name
await db.commit()
await db.refresh(user)
return {
"id": user.id,
"email": user.email,
"name": user.name,
"is_premium": user.is_premium,
"allergies": json.loads(user.allergies or "[]"),
"health_profile": user.health_profile or "normal",
}
@router.get("/profile")
async def get_profile(user: User = Depends(get_current_user)):
return {
"id": user.id,
"email": user.email,
"name": user.name,
"is_premium": user.is_premium,
"allergies": json.loads(user.allergies or "[]"),
"health_profile": user.health_profile or "normal",
}

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
import json
from datetime import datetime, timezone, date
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
@@ -10,15 +10,21 @@ from app.models.scan import Scan
from app.schemas.scan import ScanRequest, ScanResult, ScanHistoryItem
from app.utils.security import get_current_user
from app.integrations.open_food_facts import fetch_product
from app.integrations.openai_client import analyze_product
from app.integrations.openai_client import analyze_product, analyze_photo
from app.config import settings
from app.services.seed import SEED_PRODUCTS
from app.services.achievements import check_achievements
router = APIRouter(prefix="/api", tags=["scan"])
def get_user_context(user: User) -> dict:
"""Build user context for AI analysis."""
allergies = json.loads(user.allergies or "[]")
health_profile = user.health_profile or "normal"
return {"allergies": allergies, "health_profile": health_profile}
@router.post("/scan", response_model=ScanResult)
async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# Rate limit check
if not user.is_premium:
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
@@ -28,7 +34,6 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
if count >= settings.FREE_SCAN_LIMIT:
raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido. Faça upgrade para Premium!")
# Check local cache
result = await db.execute(select(Product).where(Product.barcode == req.barcode))
product = result.scalar_one_or_none()
@@ -43,12 +48,10 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
"image_url": product.image_url,
}
else:
# Check seed data
if req.barcode in SEED_PRODUCTS:
product_data = SEED_PRODUCTS[req.barcode].copy()
source = "seed"
else:
# Fetch from Open Food Facts
product_data = await fetch_product(req.barcode)
source = "open_food_facts"
@@ -66,8 +69,38 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
if not product_data:
raise HTTPException(status_code=404, detail="Produto não encontrado. Tente inserir manualmente.")
# AI Analysis
analysis = await analyze_product(product_data)
user_context = get_user_context(user)
analysis = await analyze_product(product_data, user_context=user_context)
# Add allergen alerts
allergies = json.loads(user.allergies or "[]")
allergen_alerts = []
if allergies and analysis.get("ingredients"):
for ing in analysis["ingredients"]:
ing_name = (ing.get("name", "") + " " + ing.get("popular_name", "")).lower()
for allergy in allergies:
allergy_lower = allergy.lower()
# Map common allergy names to ingredient keywords
allergy_keywords = {
"glúten": ["glúten", "trigo", "centeio", "cevada", "aveia", "farinha de trigo", "wheat", "gluten"],
"lactose": ["lactose", "leite", "soro de leite", "whey", "caseína", "lácteo", "milk", "dairy"],
"amendoim": ["amendoim", "peanut"],
"soja": ["soja", "lecitina de soja", "soy"],
"ovo": ["ovo", "albumina", "egg"],
"frutos do mar": ["camarão", "peixe", "lagosta", "caranguejo", "marisco", "fish", "shrimp"],
"nozes": ["nozes", "castanha", "amêndoa", "avelã", "nuts", "almond"],
}
keywords = allergy_keywords.get(allergy_lower, [allergy_lower])
for kw in keywords:
if kw in ing_name:
allergen_alerts.append({
"ingredient": ing.get("name", ""),
"allergy": allergy,
})
ing["is_allergen"] = True
break
analysis["allergen_alerts"] = allergen_alerts
# Save scan
scan = Scan(
@@ -77,8 +110,12 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
)
db.add(scan)
await db.commit()
# Check achievements
new_badges = await check_achievements(user.id, db, action="scan")
return ScanResult(
id=scan.id,
barcode=req.barcode,
product_name=product_data.get("name"),
brand=product_data.get("brand"),
@@ -94,6 +131,9 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
nutrition=analysis.get("nutrition"),
nutrition_verdict=analysis.get("nutrition_verdict"),
recipe=analysis.get("recipe"),
substitutions=analysis.get("substitutions"),
allergen_alerts=allergen_alerts,
new_badges=new_badges,
source=source,
)
@@ -108,37 +148,6 @@ async def get_history(user: User = Depends(get_current_user), db: AsyncSession =
brand=s.brand, score=s.score, scanned_at=s.scanned_at
) for s in scans]
@router.get("/history/{scan_id}")
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Scan).where(Scan.id == scan_id, Scan.user_id == user.id)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=404, detail="Scan não encontrado")
analysis = json.loads(scan.analysis_json or '{}')
# Also get product info
prod_result = await db.execute(select(Product).where(Product.barcode == scan.barcode))
product = prod_result.scalar_one_or_none()
return {
"id": scan.id,
"barcode": scan.barcode,
"product_name": scan.product_name,
"brand": scan.brand,
"score": scan.score,
"summary": scan.summary,
"scanned_at": scan.scanned_at.isoformat() if scan.scanned_at else None,
"category": product.category if product else None,
"image_url": product.image_url if product else None,
"nutri_score": product.nutri_score if product else None,
"nova_group": product.nova_group if product else None,
"positives": analysis.get("positives", []),
"negatives": analysis.get("negatives", []),
"ingredients": analysis.get("ingredients", []),
}
@router.get("/history/{scan_id}")
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
@@ -170,4 +179,88 @@ async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user),
"nutrition": analysis.get("nutrition", {}),
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
"recipe": analysis.get("recipe"),
"substitutions": analysis.get("substitutions"),
"allergen_alerts": analysis.get("allergen_alerts", []),
}
@router.post("/scan/photo")
async def scan_photo(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), file: UploadFile = File(...)):
if not user.is_premium:
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= today_start)
)
count = result.scalar()
if count >= settings.FREE_SCAN_LIMIT:
raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido.")
contents = await file.read()
if len(contents) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Imagem muito grande. Máximo 10MB.")
import base64
from PIL import Image
import io
try:
img = Image.open(io.BytesIO(contents))
img = img.convert("RGB")
max_dim = 1024
if max(img.size) > max_dim:
ratio = max_dim / max(img.size)
img = img.resize((int(img.size[0]*ratio), int(img.size[1]*ratio)), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Imagem inválida: {str(e)}")
user_context = get_user_context(user)
analysis = await analyze_photo(b64, user_context=user_context)
if not analysis:
raise HTTPException(status_code=422, detail="Não foi possível analisar a imagem. Tente uma foto mais nítida do rótulo.")
scan = Scan(
user_id=user.id, barcode="PHOTO",
product_name=analysis.get("product_name", "Produto (foto)"),
brand=analysis.get("brand", ""), score=analysis.get("score", 50),
summary=analysis.get("summary", ""), analysis_json=json.dumps(analysis),
)
db.add(scan)
if analysis.get("product_name"):
new_product = Product(
barcode="PHOTO_" + str(hash(b64[:100]))[-8:],
name=analysis.get("product_name"), brand=analysis.get("brand", ""),
category=analysis.get("category", ""), ingredients_text=analysis.get("ingredients_text", ""),
nutri_score=analysis.get("nutri_score"), nova_group=analysis.get("nova_group"),
nutrition_json=json.dumps(analysis.get("nutrition", {})),
)
db.add(new_product)
await db.commit()
new_badges = await check_achievements(user.id, db, action="scan")
return {
"id": scan.id,
"barcode": "PHOTO",
"product_name": analysis.get("product_name", "Produto (foto)"),
"brand": analysis.get("brand", ""),
"category": analysis.get("category", ""),
"image_url": None,
"score": analysis.get("score", 50),
"summary": analysis.get("summary", ""),
"positives": analysis.get("positives", []),
"negatives": analysis.get("negatives", []),
"ingredients": analysis.get("ingredients", []),
"nutrition": analysis.get("nutrition", {}),
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
"recipe": analysis.get("recipe"),
"substitutions": analysis.get("substitutions"),
"allergen_alerts": analysis.get("allergen_alerts", []),
"nutri_score": analysis.get("nutri_score"),
"nova_group": analysis.get("nova_group"),
"new_badges": new_badges,
"source": "photo",
}

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import json
from app.database import get_db
from app.models.scan import Scan
from app.models.product import Product
router = APIRouter(prefix="/api", tags=["share"])
@router.get("/scan/{scan_id}/share", response_class=HTMLResponse)
async def share_scan(scan_id: int, db: AsyncSession = Depends(get_db)):
res = await db.execute(select(Scan).where(Scan.id == scan_id))
scan = res.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=404, detail="Scan não encontrado")
analysis = json.loads(scan.analysis_json or '{}')
score = scan.score or 0
color = '#10B981' if score >= 70 else '#EAB308' if score >= 50 else '#F97316' if score >= 30 else '#EF4444'
label = 'Excelente' if score >= 90 else 'Bom' if score >= 70 else 'Regular' if score >= 50 else 'Ruim' if score >= 30 else 'Péssimo'
positives = ''.join(f'<li style="color:#10B981">✅ {p}</li>' for p in analysis.get("positives", []))
negatives = ''.join(f'<li style="color:#EF4444">❌ {n}</li>' for n in analysis.get("negatives", []))
html = f"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta property="og:title" content="ALETHEIA: {scan.product_name} - Score {score}/100">
<meta property="og:description" content="{scan.summary or ''}">
<title>ALETHEIA - {scan.product_name}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#0A0E17;color:white;font-family:Inter,system-ui,sans-serif;padding:20px;max-width:500px;margin:0 auto}}
.card{{background:#111827;border-radius:20px;padding:24px;margin-bottom:16px;border:1px solid rgba(255,255,255,0.05)}}
.score{{width:120px;height:120px;border-radius:50%;border:6px solid {color};display:flex;align-items:center;justify-content:center;margin:0 auto 16px;flex-direction:column}}
.score span{{font-size:36px;font-weight:900;color:{color}}}
.score small{{font-size:12px;color:#9CA3AF}}
h1{{font-size:20px;text-align:center;margin-bottom:4px}}
h2{{font-size:14px;color:#9CA3AF;text-align:center;margin-bottom:16px}}
.label{{display:inline-block;padding:4px 16px;border-radius:20px;font-weight:700;font-size:14px;color:{color};background:{color}15;text-align:center;margin:0 auto 20px;display:block;width:fit-content}}
ul{{list-style:none;padding:0}}li{{font-size:13px;margin-bottom:6px;color:#D1D5DB}}
.logo{{text-align:center;margin-top:24px;font-size:12px;color:#6B7280}}
.logo b{{background:linear-gradient(135deg,#00D4AA,#7C3AED);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
</style></head><body>
<div class="card">
<div class="score"><span>{score}</span><small>/100</small></div>
<h1>{scan.product_name or 'Produto'}</h1>
<h2>{scan.brand or ''}</h2>
<div class="label">{label}</div>
<p style="font-size:13px;color:#D1D5DB;text-align:center;margin-bottom:16px">{scan.summary or ''}</p>
{f'<ul>{positives}</ul>' if positives else ''}
{f'<ul style="margin-top:12px">{negatives}</ul>' if negatives else ''}
</div>
<div class="logo">Analisado por <b>ALETHEIA</b> — A verdade sobre o que você come</div>
</body></html>"""
return HTMLResponse(content=html)

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from app.database import get_db
from app.models.user import User
from app.models.shopping_list import ShoppingItem
from app.utils.security import get_current_user
router = APIRouter(prefix="/api", tags=["shopping"])
class ShoppingAdd(BaseModel):
product_name: str
barcode: Optional[str] = None
@router.post("/shopping-list")
async def add_to_list(data: ShoppingAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
item = ShoppingItem(user_id=user.id, product_name=data.product_name, barcode=data.barcode)
db.add(item)
await db.commit()
await db.refresh(item)
return {"id": item.id, "product_name": item.product_name, "barcode": item.barcode, "checked": item.checked, "added_at": item.added_at.isoformat()}
@router.get("/shopping-list")
async def get_list(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
res = await db.execute(
select(ShoppingItem).where(ShoppingItem.user_id == user.id).order_by(ShoppingItem.added_at.desc())
)
items = res.scalars().all()
return [{"id": i.id, "product_name": i.product_name, "barcode": i.barcode, "checked": i.checked,
"added_at": i.added_at.isoformat()} for i in items]
@router.delete("/shopping-list/{item_id}")
async def delete_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id))
item = res.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item não encontrado")
await db.delete(item)
await db.commit()
return {"ok": True}
@router.put("/shopping-list/{item_id}/toggle")
async def toggle_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id))
item = res.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item não encontrado")
item.checked = not item.checked
await db.commit()
return {"id": item.id, "checked": item.checked}

View File

@@ -0,0 +1,87 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, asc
from datetime import datetime, timezone, timedelta
from app.database import get_db
from app.models.user import User
from app.models.scan import Scan
from app.utils.security import get_current_user
router = APIRouter(prefix="/api", tags=["stats"])
@router.get("/stats")
async def get_stats(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
now = datetime.now(timezone.utc)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Total scans
total_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id))
total_scans = total_res.scalar() or 0
# Average score
avg_res = await db.execute(select(func.avg(Scan.score)).where(Scan.user_id == user.id))
avg_score = round(avg_res.scalar() or 0, 1)
# Monthly scans
monthly_res = await db.execute(
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
)
monthly_scans = monthly_res.scalar() or 0
# Top 10 best this month
best_res = await db.execute(
select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
.order_by(desc(Scan.score)).limit(10)
)
best = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score,
"scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in best_res.scalars().all()]
# Top 10 worst this month
worst_res = await db.execute(
select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
.order_by(asc(Scan.score)).limit(10)
)
worst = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score,
"scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in worst_res.scalars().all()]
return {
"total_scans": total_scans,
"avg_score": avg_score,
"monthly_scans": monthly_scans,
"best": best,
"worst": worst,
}
@router.get("/stats/evolution")
async def get_evolution(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
now = datetime.now(timezone.utc)
three_months_ago = now - timedelta(days=90)
result = await db.execute(
select(Scan.scanned_at, Scan.score)
.where(Scan.user_id == user.id, Scan.scanned_at >= three_months_ago)
.order_by(Scan.scanned_at)
)
scans = result.all()
# Group by week
weeks = {}
for scanned_at, score in scans:
# ISO week
week_key = scanned_at.strftime("%Y-W%W")
week_start = scanned_at - timedelta(days=scanned_at.weekday())
if week_key not in weeks:
weeks[week_key] = {"week": week_start.strftime("%d/%m"), "scores": [], "count": 0}
weeks[week_key]["scores"].append(score)
weeks[week_key]["count"] += 1
evolution = []
for key in sorted(weeks.keys()):
w = weeks[key]
evolution.append({
"week": w["week"],
"avg_score": round(sum(w["scores"]) / len(w["scores"]), 1),
"count": w["count"],
})
return {"evolution": evolution}