feat: add tackle combination analytics
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run

This commit is contained in:
ik
2026-09-21 08:08:23 +07:00
parent 8d7538d423
commit 87321d8384
8 changed files with 60 additions and 9 deletions
+1
View File
@@ -19,6 +19,7 @@ export type Catch = { id: string; fish: string; weight_g: number; bait: string |
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
export type TackleCombination = { role: string; value: string; catches: number; unique_players: number; last_seen_at: string; status: "recommendation" | "insufficient_data"; explanation: string };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
export type PaginatedOfficialRecord = {
items: OfficialRecord[];
+40
View File
@@ -0,0 +1,40 @@
---
import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import { ago, api, type DictionaryItem, type TackleCombination } from "../../lib/api";
const params = Astro.url.searchParams;
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const method = params.get("method") ?? "";
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);
let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false;
try {
[combinations, waters, fishes] = await Promise.all([
api<TackleCombination[]>(`/api/v1/analytics/tackle?${query}`),
api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"),
api<DictionaryItem[]>("/api/v1/fishes?limit=500"),
]);
} catch { unavailable = true; }
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
---
<Layout title="Сочетания снастей RF4 — RF4 Spotter" description="Подтверждённые сочетания снастей RF4 по одобренным наблюдениям, с выборкой и независимыми игроками.">
<PageHero eyebrow="Только одобренные наблюдения" title="Сочетания снастей" description="Факт использования отделён от рекомендации: малой выборке нельзя приписывать эффективность." variant="fish" />
<form class="record-filters" method="get" aria-label="Фильтры сочетаний снастей">
<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="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>}
</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 => <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>{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>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>)}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}
</Layout>
+1
View File
@@ -35,6 +35,7 @@ if (family) filterParams.set("family", family);
<label>Семейство<input name="family" value={family} maxlength="100" placeholder="Название семейства" /></label>
<button data-action="primary">Фильтровать</button>
{(category || brand || family) && <a data-action="quiet" href="/tackle">Сбросить</a>}
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small><div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}