From e31fbe996d41d69bb3fd7a016998a7b6aed09302 Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 20 Sep 2026 20:03:34 +0700 Subject: [PATCH] feat: standardize catalog evidence --- apps/web/src/components/DataPassport.astro | 4 ++-- apps/web/src/pages/tackle/index.astro | 3 ++- apps/web/src/pages/waterbodies/[slug].astro | 2 ++ apps/web/src/styles/query-context.css | 1 + apps/web/tests/catalog-evidence.spec.ts | 12 ++++++++++++ docs/ROADMAP.md | 2 +- 6 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 apps/web/tests/catalog-evidence.spec.ts diff --git a/apps/web/src/components/DataPassport.astro b/apps/web/src/components/DataPassport.astro index 07a550a..9c9e7fb 100644 --- a/apps/web/src/components/DataPassport.astro +++ b/apps/web/src/components/DataPassport.astro @@ -1,7 +1,7 @@ --- import SourceBadge from "./SourceBadge.astro"; import { ago } from "../lib/api"; -type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; status?: "verified" | "unverified" | "incomplete" }; +type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; status?: "verified" | "unverified" | "incomplete" }; const { sources, sourceUrl, observedAt, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, status = "verified" } = Astro.props as Props; const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" }; const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`; @@ -10,5 +10,5 @@ const precisionLabels = { exact: "точные", approximate: "приблизи
Паспорт данных{statusLabels[status]}
{sources.map(source => )}
-
Свежесть
{ago(observedAt)}
Полнота
{completenessLabel}
Доверие
{confidence == null ? "После проверки" : `${confidence}%`}
{sampleSize != null &&
Наблюдения
{sampleSize}
}{independentPlayers != null &&
Игроки
{independentPlayers}
}{coordinatePrecision &&
Координаты
{precisionLabels[coordinatePrecision]}
}
+
Свежесть
{observedAt ? ago(observedAt) : "Не указана"}
Полнота
{completenessLabel}
Доверие
{confidence == null ? "После проверки" : `${confidence}%`}
{sampleSize != null &&
Наблюдения
{sampleSize}
}{independentPlayers != null &&
Игроки
{independentPlayers}
}{coordinatePrecision &&
Координаты
{precisionLabels[coordinatePrecision]}
}
diff --git a/apps/web/src/pages/tackle/index.astro b/apps/web/src/pages/tackle/index.astro index 8efa92b..e1757f0 100644 --- a/apps/web/src/pages/tackle/index.astro +++ b/apps/web/src/pages/tackle/index.astro @@ -4,6 +4,7 @@ import PageHero from "../../components/PageHero.astro"; import StatePanel from "../../components/StatePanel.astro"; import Pagination from "../../components/Pagination.astro"; import TackleGlyph from "../../components/TackleGlyph.astro"; +import DataPassport from "../../components/DataPassport.astro"; import { api, type PaginatedTackleItems } from "../../lib/api"; const params = Astro.url.searchParams; @@ -35,6 +36,6 @@ if (family) filterParams.set("family", family); {(category || brand || family) && Сбросить} - {unavailable ? : result.items.length ?
{result.items.map(item => {categoryLabels[item.category] ?? item.category}{item.name}{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"})}
: } + {unavailable ? : result.items.length ?
{result.items.map(item => {categoryLabels[item.category] ?? item.category}{item.name}{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}
)}
: } {!unavailable && } diff --git a/apps/web/src/pages/waterbodies/[slug].astro b/apps/web/src/pages/waterbodies/[slug].astro index 134dcd7..ce1edfe 100644 --- a/apps/web/src/pages/waterbodies/[slug].astro +++ b/apps/web/src/pages/waterbodies/[slug].astro @@ -5,6 +5,7 @@ import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro"; import AtlasEntityLink from "../../components/AtlasEntityLink.astro"; import PageHero from "../../components/PageHero.astro"; import StatePanel from "../../components/StatePanel.astro"; +import DataPassport from "../../components/DataPassport.astro"; import Layout from "../../layouts/Layout.astro"; import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api"; import { findMediaByLabel } from "../../lib/media"; @@ -31,5 +32,6 @@ const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPag {image &&
Карта и образ водоёма

{water!.name_ru}

Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.

} {water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) &&
Канонические сведения

{water.name_ru}

{water.description &&

{water.description}

}{water.unlock_level !== undefined && water.unlock_level !== null &&

Открывается с уровня: {water.unlock_level}

}{water.source_fish_species?.length &&

В карточке источника указано видов рыб: {water.source_fish_species.length}.

}{water.source_aliases?.length &&

Алиасы источника: {water.source_aliases.join(", ")}.

}
Источник{water.source_url ?

{water.source_system ?? "Внешний источник"} · исходная карточка

:

Источник для описания ещё не подтверждён.

}{water.source_point_urls?.length &&

Сохранено ссылок на точки: {water.source_point_urls.length}.

}{water.source_image_urls?.length &&

Изображений-кандидатов: {water.source_image_urls.length}; публикация требует отдельной проверки.

}{water.source_checked_at &&

Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}

}
} + {water && } {unavailable ? : !water ? :
{items.length ? items.map(item => ) : }
} diff --git a/apps/web/src/styles/query-context.css b/apps/web/src/styles/query-context.css index 6163905..e3fec2e 100644 --- a/apps/web/src/styles/query-context.css +++ b/apps/web/src/styles/query-context.css @@ -5,4 +5,5 @@ .query-summary__filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px} .query-summary__filters a{margin-left:4px;color:#4d625e;font-size:13px;text-underline-offset:3px} .detail-action-heading{margin:27px 0 8px;font:400 24px Georgia,serif;color:var(--lime)} +.tackle-card-passport{grid-column:1/-1;min-width:0}.tackle-card-passport .data-passport{margin-top:14px} @media(max-width:720px){.query-summary{display:block;padding:16px 14px 0;width:100%}.query-summary h2{font-size:23px}.query-summary__filters{justify-content:flex-start;flex-wrap:nowrap;overflow-x:auto;padding-top:11px;scroll-snap-type:x proximity;scrollbar-width:none}.query-summary__filters::-webkit-scrollbar{display:none}.query-summary__filters span{flex:0 0 auto;scroll-snap-align:start}.query-summary__filters a{position:sticky;right:0;flex:0 0 auto;padding:6px 10px;background:var(--paper)}} diff --git a/apps/web/tests/catalog-evidence.spec.ts b/apps/web/tests/catalog-evidence.spec.ts new file mode 100644 index 0000000..6bd9b9b --- /dev/null +++ b/apps/web/tests/catalog-evidence.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from "@playwright/test"; + +test("waterbody passport and honest empty tackle catalog are explicit", async ({ page }) => { + await page.goto("/waterbodies/р-вьюнок"); + const waterPassport = page.locator('.data-passport[aria-label="Паспорт данных"]'); + await expect(waterPassport).toBeVisible(); + await expect(waterPassport.getByText("Паспорт данных")).toBeVisible(); + await expect(waterPassport.getByText("Свежесть")).toBeVisible(); + + await page.goto("/tackle"); + await expect(page.getByText("Подтверждённых карточек пока нет")).toBeVisible(); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cebb853..8436970 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -94,7 +94,7 @@ - [ ] **U01 · UX-контракт и scorecard.** Зафиксировать task-based навигацию, смысловой порядок ответа, словарь статусов (`fresh`, `stale`, `insufficient_data`, `blocked`, `temporary_error`) и единый scorecard: time-to-first-useful-answer, filter-to-result steps, completion rate, overflow, axe и keyboard regression. Сверить каждый публичный маршрут с контрактом; не добавлять фичи, которые не улучшают решение игрока. - [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная уже сохраняет рыбу, водоём, период и сортировку в shareable URL и после поиска явно показывает контекст запроса, включая мобильный режим; empty-state предлагает вернуться к полному набору данных. Осталось показать 3–5 вариантов с понятным CTA и добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры. -- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт теперь используется и на activity-карточках, и на detail точки: freshness, confidence, число наблюдений, независимые игроки, `coordinate_precision`, source badges и явный статус `Неполные данные` при малой выборке; targeted E2E проверяет оба пути. Осталось протянуть его на waterbody и tackle, добавить период выборки, completeness, конфликт источников и правило минимальной выборки для любых рекомендаций. Нельзя показывать «лучшую точку» или AI-like рекомендацию без объяснения и минимальной выборки. +- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness, completeness, confidence/статус, source badges и доступные доменные поля; targeted E2E проверяет публичные пути. Осталось добавить период выборки, конфликт источников и правило минимальной выборки для любых рекомендаций. Нельзя показывать «лучшую точку» или AI-like рекомендацию без объяснения и минимальной выборки. - [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу. - [ ] **U05 · Mobile-first и сохранённый план рыбалки.** Добавить лёгкий сценарий сравнения и сохранения 3–5 вариантов без обязательного аккаунта: рыба, водоём, координаты, метод, наживка, свежесть, доверие и риск. Acceptance: shareable URL/print-карточка, 320/390 px, восстановление после reload, удаление локального плана без внешнего сервиса. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, не только цветом. Осталось сократить конкурирующие цифры на карточках и провести ручной review на 5 ключевых маршрутах.