chore: call rigs assemblies in UI
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 19:54:06 +07:00
parent f0b396b598
commit 206343895e
8 changed files with 19 additions and 19 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { ago, kg, type Catch } from "../lib/api";
import TackleGlyph from "./TackleGlyph.astro"; import TackleGlyph from "./TackleGlyph.astro";
const { catches } = Astro.props as { catches: Catch[] }; const { catches } = Astro.props as { catches: Catch[] };
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" }; const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" }; const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
--- ---
<div class="catch-list"> <div class="catch-list">
{catches.map(item => { {catches.map(item => {
+1 -1
View File
@@ -7,7 +7,7 @@ const mediaRoleLabels: Record<MediaRole, string> = {
waterbody_screenshot: "Скриншот водоёма", waterbody_screenshot: "Скриншот водоёма",
tackle_card: "Карточка снасти", tackle_card: "Карточка снасти",
tackle_detail: "Деталь снасти", tackle_detail: "Деталь снасти",
rig_diagram: "Схема монтажа", rig_diagram: "Схема сборки",
tackle_screenshot: "Скриншот снасти", tackle_screenshot: "Скриншот снасти",
}; };
+1 -1
View File
@@ -8,7 +8,7 @@ const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
const requested = Astro.url.searchParams.get("type") ?? "fish"; const requested = Astro.url.searchParams.get("type") ?? "fish";
const type = allowed.has(requested) ? requested : "fish"; const type = allowed.has(requested) ? requested : "fish";
const requestedRole = Astro.url.searchParams.get("role") ?? ""; const requestedRole = Astro.url.searchParams.get("role") ?? "";
const roleOptions: Record<string, Array<[string, string]>> = { waterbody: [["waterbody_cover", "Заставки"], ["waterbody_map", "Карты"], ["waterbody_depth_map", "Карты глубин"], ["waterbody_screenshot", "Скриншоты"]], tackle: [["tackle_card", "Карточки снастей"], ["tackle_detail", "Детали снастей"], ["rig_diagram", "Схемы монтажей"], ["tackle_screenshot", "Скриншоты снастей"]] }; const roleOptions: Record<string, Array<[string, string]>> = { waterbody: [["waterbody_cover", "Заставки"], ["waterbody_map", "Карты"], ["waterbody_depth_map", "Карты глубин"], ["waterbody_screenshot", "Скриншоты"]], tackle: [["tackle_card", "Карточки снастей"], ["tackle_detail", "Детали снастей"], ["rig_diagram", "Схемы сборок"], ["tackle_screenshot", "Скриншоты снастей"]] };
const selectedRole = (roleOptions[type] ?? []).some(([value]) => value === requestedRole) ? requestedRole : ""; const selectedRole = (roleOptions[type] ?? []).some(([value]) => value === requestedRole) ? requestedRole : "";
let assets: MediaAsset[] = [], unavailable = false; let assets: MediaAsset[] = [], unavailable = false;
try { const query = new URLSearchParams({ entity_type: type }); if (selectedRole) query.set("media_role", selectedRole); assets = await api<MediaAsset[]>(`/api/v1/media/catalog?${query}`); } catch { unavailable = true; } try { const query = new URLSearchParams({ entity_type: type }); if (selectedRole) query.set("media_role", selectedRole); assets = await api<MediaAsset[]>(`/api/v1/media/catalog?${query}`); } catch { unavailable = true; }
+1 -1
View File
@@ -25,7 +25,7 @@ try {
} catch { unavailable = true; } } catch { unavailable = true; }
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); } 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 methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" }; const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
--- ---
<Layout title="Сочетания снастей RF4 — RF4 Spotter" description="Подтверждённые сочетания снастей RF4 по одобренным наблюдениям, с выборкой и независимыми игроками." noindex={unavailable} errorPage={unavailable}> <Layout title="Сочетания снастей RF4 — RF4 Spotter" description="Подтверждённые сочетания снастей RF4 по одобренным наблюдениям, с выборкой и независимыми игроками." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Только одобренные наблюдения" title="Сочетания снастей" description="Факт использования отделён от рекомендации: малой выборке нельзя приписывать эффективность." variant="fish" /> <PageHero eyebrow="Только одобренные наблюдения" title="Сочетания снастей" description="Факт использования отделён от рекомендации: малой выборке нельзя приписывать эффективность." variant="fish" />
+3 -3
View File
@@ -28,14 +28,14 @@ const [itemsResult, rigsResult] = await Promise.allSettled([
if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true; if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true;
if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true; if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true;
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); } if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" }; const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
const filterParams = new URLSearchParams(); const filterParams = new URLSearchParams();
if (category) filterParams.set("category", category); if (category) filterParams.set("category", category);
if (search) filterParams.set("q", search); if (search) filterParams.set("q", search);
if (brand) filterParams.set("brand", brand); if (brand) filterParams.set("brand", brand);
if (family) filterParams.set("family", family); if (family) filterParams.set("family", family);
--- ---
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и монтажей Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailable}> <Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} /> <PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей"> <form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label> <label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
@@ -47,6 +47,6 @@ if (family) filterParams.set("family", family);
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a> <a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
</form> </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 ? <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 && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Монтажи</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Монтажи временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Монтаж · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых монтажей пока нет" description="Монтажи появятся после разрешённой загрузки и проверки источников." />}</section>} {!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Сборка · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых сборок пока нет" description="Сборки появятся после разрешённой загрузки и проверки источников." />}</section>}
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />} {!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
</Layout> </Layout>
+1 -1
View File
@@ -23,7 +23,7 @@ if (unavailable) {
Astro.response.headers.set("Cache-Control", "no-store"); Astro.response.headers.set("Cache-Control", "no-store");
} }
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" }; const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
const missingLabels: Record<string, string> = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" }; const missingLabels: Record<string, string> = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" };
const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? []; const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
const sourceHref = safeHttpUrl(item?.source_url); const sourceHref = safeHttpUrl(item?.source_url);
+7 -7
View File
@@ -23,17 +23,17 @@ if (unavailable) {
Astro.response.headers.set("Cache-Control", "no-store"); Astro.response.headers.set("Cache-Control", "no-store");
} }
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" }; const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
const missingLabels: Record<string, string> = { source_url: "ссылка на источник", source_checked_at: "дата проверки" }; const missingLabels: Record<string, string> = { source_url: "ссылка на источник", source_checked_at: "дата проверки" };
const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? []; const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
const sourceHref = safeHttpUrl(rig?.source_url); const sourceHref = safeHttpUrl(rig?.source_url);
--- ---
<Layout title={rig ? `${rig.name} — монтажи RF4` : "Монтаж не найден — RF4 Spotter"} description={rig ? `Подтверждённая карточка монтажа ${rig.name} в каталоге RF4 с составом и источником.` : "Такой карточки монтажа нет в каталоге RF4."} noindex={!rig || unavailable} errorPage={!rig || unavailable}> <Layout title={rig ? `${rig.name} — сборки RF4` : "Сборка не найдена — RF4 Spotter"} description={rig ? `Подтверждённая карточка сборки ${rig.name} в каталоге RF4 с составом и источником.` : "Такой карточки сборки нет в каталоге RF4."} noindex={!rig || unavailable} errorPage={!rig || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: "Монтажи" }, { label: rig?.name ?? "Не найдено" }]} /> <AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: "Сборки" }, { label: rig?.name ?? "Не найдено" }]} />
<PageHero eyebrow="Канонический монтаж" title={rig?.name ?? (unavailable ? "Каталог недоступен" : "Монтаж не найден")} description={rig ? "Состав монтажа показан в исходном порядке; неподтверждённые значения не дополняются догадками." : undefined} variant={rig ? "fish" : undefined} identity={rig?.name} /> <PageHero eyebrow="Каноническая сборка" title={rig?.name ?? (unavailable ? "Каталог недоступен" : "Сборка не найдена")} description={rig ? "Состав сборки показан в исходном порядке; неподтверждённые значения не дополняются догадками." : undefined} variant={rig ? "fish" : undefined} identity={rig?.name} />
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенный состав не показываем." /> : !rig ? <StatePanel tone="error" title="Такого монтажа нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог снастей" /> : <> {unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенный состав не показываем." /> : !rig ? <StatePanel tone="error" title="Такой сборки нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог снастей" /> : <>
<section class="tackle-detail content-grid" aria-label="Состав монтажа"> <section class="tackle-detail content-grid" aria-label="Состав сборки">
<div class="tackle-detail__identity"><TackleGlyph name={rig.name} size={72} /><span class="overline">Монтаж</span><h2>{rig.name}</h2><p>{rig.source_external_id ? `Идентификатор источника: ${rig.source_external_id}` : "Внешний идентификатор не указан."}</p></div> <div class="tackle-detail__identity"><TackleGlyph name={rig.name} size={72} /><span class="overline">Сборка</span><h2>{rig.name}</h2><p>{rig.source_external_id ? `Идентификатор источника: ${rig.source_external_id}` : "Внешний идентификатор не указан."}</p></div>
<div><span class="overline">Компоненты</span><ol class="rig-components">{rig.components.length ? rig.components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value ?? "Карточка снасти"}</a> : <span>{component.raw_value ?? "Значение не указано"}</span>}</li>) : <li>Состав не указан.</li>}</ol>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div> <div><span class="overline">Компоненты</span><ol class="rig-components">{rig.components.length ? rig.components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value ?? "Карточка снасти"}</a> : <span>{component.raw_value ?? "Значение не указано"}</span>}</li>) : <li>Состав не указан.</li>}</ol>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div>
</section> </section>
<DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"} /> <DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"} />
+4 -4
View File
@@ -63,16 +63,16 @@
Этот пакет повторяет жизненный цикл W01–W08, но не смешивает разные уровни Этот пакет повторяет жизненный цикл W01–W08, но не смешивает разные уровни
описания. Наживка/приманка — предмет, который указан в улове; снасть — описания. Наживка/приманка — предмет, который указан в улове; снасть —
компонент комплекта (удилище, катушка, леска, крючок и т. п.); оснастка — компонент комплекта (удилище, катушка, леска, крючок и т. п.); оснастка —
собранная схема или монтаж (например, донная, поплавочная, method). Число собранная схема или сборка (например, донная, поплавочная, method). Число
найденных изображений приманок не считается размером полного каталога. найденных изображений приманок не считается размером полного каталога.
- [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом. - [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом.
- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы монтажа сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена. - [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы сборок сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
- [ ] **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. - [ ] **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 и вручную подтвердить результаты. - [ ] **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. - [ ] **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` и `/api/v1/tackle/rigs` с фильтрами предметов и монтажей по названию, а также предметов по категории, бренду, семейству и уровню; 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 оставляет текстом. - [ ] **G06 · API и публичный каталог.** Добавлены пагинированные `/api/v1/tackle/items` и `/api/v1/tackle/rigs` с фильтрами предметов и сборок по названию, а также предметов по категории, бренду, семейству и уровню; 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-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или монтажа, а при конфликте идентификаторов сохраняет raw-значение без ложной ссылки. Остаётся дальнейшая визуальная приёмка. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода, роли и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или сборки, а при конфликте идентификаторов сохраняет 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 для будущих ролей; публичная медиатека фильтрует разрешённые роли через URL/API, admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. - [ ] **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 для будущих ролей; публичная медиатека фильтрует разрешённые роли через URL/API, 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. - [ ] **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.