diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index be05f30..84e893c 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -276,6 +276,7 @@ def test_spot_detail_and_catches() -> None: assert len(catches.json()) == 3 assert catches.json()[0]["source_system"] == "manual-import" assert catches.json()[0]["fishing_method"] == "spinning" + assert catches.json()[0]["tackle_components"] == [] fixture = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20") assert fixture.status_code == 200 fixture_catches = client.get(f"/api/v1/spots/{fixture.json()['id']}/catches") diff --git a/apps/web/src/components/CatchList.astro b/apps/web/src/components/CatchList.astro index 6c2bfe1..51ac922 100644 --- a/apps/web/src/components/CatchList.astro +++ b/apps/web/src/components/CatchList.astro @@ -4,16 +4,17 @@ import { ago, kg, type Catch } from "../lib/api"; import TackleGlyph from "./TackleGlyph.astro"; const { catches } = Astro.props as { catches: Catch[] }; const methodLabels: Record = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" }; +const roleLabels: Record = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" }; ---
{catches.map(item => { const timestamp = item.caught_at ?? item.reported_at; return
-
{item.fish}{item.bait ?? "Приманка не указана"}{item.fishing_method && Метод: {methodLabels[item.fishing_method] ?? item.fishing_method}}{item.retrieve_method && Проводка: {item.retrieve_method}{item.retrieve_speed != null ? ` · скорость ${item.retrieve_speed}` : ""}}
+
{item.fish}{item.bait ?? "Приманка не указана"}{item.fishing_method && Метод: {methodLabels[item.fishing_method] ?? item.fishing_method}}{item.retrieve_method && Проводка: {item.retrieve_method}{item.retrieve_speed != null ? ` · скорость ${item.retrieve_speed}` : ""}}{item.tackle_components?.length > 0 && Комплект: {item.tackle_components.map(component => <>{roleLabels[component.role] ?? component.role}: {component.tackle_item_id ? {component.raw_value} : component.raw_value})}}
{kg(item.weight_g)}{item.player_name ?? "Анонимно"}{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}
; })}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ac83f60..95964f4 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -15,7 +15,8 @@ export type PaginatedActivity = { }; export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[] }; -export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; fishing_method: string | null; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null }; +export type CatchTackleComponent = { id: string; role: string; position: number; raw_value: string; tackle_item_id: string | null; rig_id: string | null; source_system: string | null; source_url: string | null }; +export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; fishing_method: string | null; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null; tackle_components: CatchTackleComponent[] }; 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 }; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ae03d49..2fba161 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,7 +71,7 @@ - [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk. - [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты. - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk. -- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-route `/tackle/items/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; остаётся подключить канонические ссылки из представлений улова после подтверждения crosswalk. +- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-route `/tackle/items/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты, ведёт в canonical detail только при наличии `tackle_item_id`, а unresolved raw value оставляет текстом. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает фильтры, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Decay по свежести и дальнейшая визуальная приёмка остаются следующим шагом. - [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. - [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff.