diff --git a/apps/web/src/components/CatchList.astro b/apps/web/src/components/CatchList.astro index 078d4cd..3845ceb 100644 --- a/apps/web/src/components/CatchList.astro +++ b/apps/web/src/components/CatchList.astro @@ -10,7 +10,7 @@ const roleLabels: Record = { lure: "Приманка", bait: "Н {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.tackle_components?.length > 0 &&
Комплект снастей ({item.tackle_components.length})
    {item.tackle_components.map(component =>
  • {roleLabels[component.role] ?? component.role}{component.tackle_item_id ? {component.raw_value} : {component.raw_value}}
  • )}
}
+
{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.length})
}
{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 d57c94f..5ffc658 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -16,6 +16,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 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 RigComponent = { id: string; role: string; position: number; raw_value: string | null; tackle_item_id: string | null }; +export type Rig = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; components: RigComponent[] }; 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[] }; diff --git a/apps/web/src/pages/tackle/rigs/[id].astro b/apps/web/src/pages/tackle/rigs/[id].astro new file mode 100644 index 0000000..0d9962f --- /dev/null +++ b/apps/web/src/pages/tackle/rigs/[id].astro @@ -0,0 +1,48 @@ +--- +import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro"; +import DataPassport from "../../../components/DataPassport.astro"; +import Layout from "../../../layouts/Layout.astro"; +import PageHero from "../../../components/PageHero.astro"; +import StatePanel from "../../../components/StatePanel.astro"; +import TackleGlyph from "../../../components/TackleGlyph.astro"; +import { ApiError, api, type Rig } from "../../../lib/api"; +import { safeHttpUrl } from "../../../lib/urls"; + +const { id } = Astro.params; +let rig: Rig | undefined; +let unavailable = false; +try { + rig = await api(`/api/v1/tackle/rigs/${encodeURIComponent(id ?? "")}`); +} catch (error) { + unavailable = !(error instanceof ApiError) || error.status >= 500; +} +if (!rig && !unavailable) Astro.response.status = 404; +if (unavailable) { + Astro.response.status = 503; + Astro.response.headers.set("Retry-After", "60"); + Astro.response.headers.set("Cache-Control", "no-store"); +} + +const roleLabels: Record = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" }; +const missingLabels: Record = { source_url: "ссылка на источник", source_checked_at: "дата проверки" }; +const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? []; +const sourceHref = safeHttpUrl(rig?.source_url); +--- + + + + {unavailable ? : !rig ? : <> +
+
Монтаж

{rig.name}

{rig.source_external_id ? `Идентификатор источника: ${rig.source_external_id}` : "Внешний идентификатор не указан."}

+
Компоненты
    {rig.components.length ? rig.components.map(component =>
  1. {roleLabels[component.role] ?? component.role}{component.tackle_item_id ? {component.raw_value ?? "Карточка снасти"} : {component.raw_value ?? "Значение не указано"}}
  2. ) :
  3. Состав не указан.
  4. }
{missing.length > 0 &&

Не хватает: {missing.join(", ")}.

}{sourceHref && Открыть первоисточник}
+
+ + } +
+ + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 09585f8..1b7fb41 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-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты, ведёт в canonical detail только при наличии `tackle_item_id`, а unresolved raw value оставляет текстом. +- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или монтажа только при наличии соответствующего ID, а unresolved raw value оставляет текстом. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. Остаётся дальнейшая визуальная приёмка. - [ ] **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 и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный 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.