feat: filter tackle analytics by role

This commit is contained in:
ik
2026-09-21 19:48:49 +07:00
parent b7d69619fa
commit 6fa669b4ef
3 changed files with 15 additions and 7 deletions
+10 -5
View File
@@ -4,7 +4,7 @@ from collections import defaultdict
from datetime import datetime, timedelta, timezone
from math import exp, log
from fastapi import APIRouter, Query
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload, selectinload
@@ -27,10 +27,13 @@ def tackle_combinations(
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
role: str | None = None,
hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=1, le=100),
min_players: int = Query(2, ge=1, le=100),
) -> list[TackleCombinationOut]:
if role is not None and role not in {"lure", "bait", "rig", "rod", "reel", "line", "hook", "float", "sinker"}:
raise HTTPException(status_code=422, detail="invalid tackle role")
now = datetime.now(timezone.utc)
query = select(CatchReport).options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
@@ -50,23 +53,25 @@ def tackle_combinations(
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
for report in db.scalars(query):
for component in report.tackle_components:
if role and component.role != role:
continue
if component.raw_value.strip():
groups[(component.role, component.raw_value.strip())].append(report)
result = []
for (role, value), reports in groups.items():
for (component_role, value), reports in groups.items():
unique_reports = {report.id: report for report in reports}
item_ids = {
component.tackle_item_id
for report in unique_reports.values()
for component in report.tackle_components
if component.role == role and component.raw_value.strip() == value and component.tackle_item_id is not None
if component.role == component_role and component.raw_value.strip() == value and component.tackle_item_id is not None
}
rig_ids = {
component.rig_id
for report in unique_reports.values()
for component in report.tackle_components
if component.role == role and component.raw_value.strip() == value and component.rig_id is not None
if component.role == component_role and component.raw_value.strip() == value and component.rig_id is not None
}
canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids else None
canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids else None
@@ -80,7 +85,7 @@ def tackle_combinations(
) / catches * 100)
enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut(
role=role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id,
role=component_role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id,
catches=catches, unique_players=unique_players,
last_seen_at=last_seen, freshness_score=freshness_score,
status="recommendation" if enough else "insufficient_data",
+4 -1
View File
@@ -8,11 +8,13 @@ const params = Astro.url.searchParams;
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const method = params.get("method") ?? "";
const role = params.get("role") ?? "";
const hours = [24, 72, 168].includes(Number(params.get("hours"))) ? Number(params.get("hours")) : 72;
const query = new URLSearchParams({ hours: String(hours), min_samples: "3", min_players: "2" });
if (waterbody) query.set("waterbody", waterbody);
if (fish) query.set("fish", fish);
if (method) query.set("method", method);
if (role) query.set("role", role);
let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false;
try {
[combinations, waters, fishes] = await Promise.all([
@@ -31,9 +33,10 @@ const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Н
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waters.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Все виды</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label>
<label>Метод<select name="method"><option value="">Все методы</option>{Object.entries(methodLabels).map(([value, label]) => <option value={value} selected={method === value}>{label}</option>)}</select></label>
<label>Роль<select name="role"><option value="">Все роли</option>{Object.entries(roleLabels).map(([value, label]) => <option value={value} selected={role === value}>{label}</option>)}</select></label>
<label>Период<select name="hours">{[24, 72, 168].map(value => <option value={value} selected={hours === value}>{value} ч</option>)}</select></label>
<button data-action="primary">Показать</button>
{(waterbody || fish || method || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
{(waterbody || fish || method || role || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
</form>
<p class="privacy content-grid">Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.</p>
{unavailable ? <StatePanel tone="unavailable" title="Аналитика временно недоступна" description="Не показываем непроверенные сочетания. Попробуйте позже." /> : combinations.length ? <section class="signal-grid content-grid" aria-label="Сочетания снастей" data-analytics-results>{combinations.map(item => { const canonicalHref = item.rig_id ? `/tackle/rigs/${item.rig_id}` : item.tackle_item_id ? `/tackle/items/${item.tackle_item_id}` : null; return <article class="signal-card" data-analytics-status={item.status}><div class="signal-card__top"><span class="source-chip"><span>{roleLabels[item.role] ?? item.role}</span></span><span class="quality-chip">{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}</span></div><h2>{canonicalHref ? <a href={canonicalHref}>{item.value}</a> : item.value}</h2><dl><div><dt>Наблюдения</dt><dd>{item.catches}</dd></div><div><dt>Игроки</dt><dd>{item.unique_players}</dd></div><div><dt>Свежесть выборки</dt><dd>{item.freshness_score}%</dd></div><div><dt>Последнее</dt><dd>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>})}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}