feat: expose explicit source conflicts

This commit is contained in:
ik
2026-09-21 07:31:17 +07:00
parent bbed832eb8
commit 36d704d854
11 changed files with 41 additions and 11 deletions
+11
View File
@@ -80,6 +80,7 @@ def activity_rows(
sources=coordinate_sources, sources=coordinate_sources,
coordinate_precision=coordinate_precision, coordinate_precision=coordinate_precision,
coordinate_sources=coordinate_sources, coordinate_sources=coordinate_sources,
source_conflicts=_source_conflicts(items),
)) ))
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True) return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
@@ -95,6 +96,16 @@ def _source_system(report: CatchReport) -> str:
return "manual-import" return "manual-import"
def _source_conflicts(reports: list[CatchReport]) -> list[str]:
conflicts: set[str] = set()
for report in reports:
provenance = (report.raw_payload or {}).get("provenance", {})
values = provenance.get("conflicts", []) if isinstance(provenance, dict) else []
if isinstance(values, list):
conflicts.update(str(value).strip() for value in values if str(value).strip())
return sorted(conflicts)
def _coordinate_precision(report: CatchReport) -> str: def _coordinate_precision(report: CatchReport) -> str:
provenance = (report.raw_payload or {}).get("provenance", {}) provenance = (report.raw_payload or {}).get("provenance", {})
value = provenance.get("coordinate_precision") if isinstance(provenance, dict) else None value = provenance.get("coordinate_precision") if isinstance(provenance, dict) else None
+2 -2
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Query, Response
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy.orm import Session, joinedload, selectinload
from ..activity import activity_rows from ..activity import _source_conflicts, activity_rows
from ..config import settings from ..config import settings
from ..dependencies import Db from ..dependencies import Db
from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody
@@ -76,7 +76,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
precisions = [item.get("coordinate_precision") for item in provenance] precisions = [item.get("coordinate_precision") for item in provenance]
precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact") precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact")
sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"] sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"]
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources) return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources, source_conflicts=_source_conflicts(reports))
def _report_source(report: CatchReport) -> str: def _report_source(report: CatchReport) -> str:
+2
View File
@@ -102,6 +102,7 @@ class ActivityOut(BaseModel):
sources: list[str] sources: list[str]
coordinate_precision: str coordinate_precision: str
coordinate_sources: list[str] coordinate_sources: list[str]
source_conflicts: list[str] = Field(default_factory=list)
class PaginatedActivityOut(BaseModel): class PaginatedActivityOut(BaseModel):
@@ -160,6 +161,7 @@ class SpotOut(BaseModel):
top_baits: list[str] top_baits: list[str]
coordinate_precision: str coordinate_precision: str
coordinate_sources: list[str] coordinate_sources: list[str]
source_conflicts: list[str] = Field(default_factory=list)
class OfficialRecordOut(BaseModel): class OfficialRecordOut(BaseModel):
+14
View File
@@ -55,6 +55,20 @@ def test_activity_filters_and_explains_score() -> None:
assert payload["items"][0]["unique_players"] == 3 assert payload["items"][0]["unique_players"] == 3
assert "3 свежих улова" in payload["items"][0]["explanation"] assert "3 свежих улова" in payload["items"][0]["explanation"]
assert payload["items"][0]["sources"] == ["manual-import"] assert payload["items"][0]["sources"] == ["manual-import"]
assert payload["items"][0]["source_conflicts"] == []
def test_explicit_source_conflict_is_exposed_on_activity_and_spot() -> None:
with Session(engine) as db:
report = db.scalar(select(CatchReport).where(CatchReport.waterbody.has(slug="test-lake")))
assert report is not None
report.raw_payload = {"provenance": {"conflicts": ["координаты: 10:20 · 11:21"]}}
spot_id = report.spot_id
db.commit()
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=6").json()
assert activity["items"][0]["source_conflicts"] == ["координаты: 10:20 · 11:21"]
spot = client.get(f"/api/v1/spots/{spot_id}").json()
assert spot["source_conflicts"] == ["координаты: 10:20 · 11:21"]
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None: def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
+1 -1
View File
@@ -18,7 +18,7 @@ const precision = { exact: "точные", approximate: "приблизител
<FishSilhouette name={item.fish}/> <FishSilhouette name={item.fish}/>
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y} · {precision}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div> <div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y} · {precision}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
<p class="data-note">{item.explanation}</p> <p class="data-note">{item.explanation}</p>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} periodLabel={periodLabel} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} status={limited ? "insufficient" : "verified"}/> <DataPassport sources={item.sources} observedAt={item.last_confirmed_at} periodLabel={periodLabel} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} sourceConflicts={item.source_conflicts} status={limited ? "insufficient" : "verified"}/>
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>{limited ? "Нужно ещё подтверждений" : item.best_bait ? "Работает сейчас" : "Наживка не указана"}</span><strong>{item.best_bait ?? "не указана"}</strong></div></div> <div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>{limited ? "Нужно ещё подтверждений" : item.best_bait ? "Работает сейчас" : "Наживка не указана"}</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
<span class="card-cta">Открыть точку <span aria-hidden="true">→</span></span> <span class="card-cta">Открыть точку <span aria-hidden="true">→</span></span>
</div> </div>
+5 -4
View File
@@ -1,17 +1,18 @@
--- ---
import SourceBadge from "./SourceBadge.astro"; import SourceBadge from "./SourceBadge.astro";
import { ago, freshnessStatus } from "../lib/api"; import { ago, freshnessStatus } from "../lib/api";
type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" }; type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; sourceConflicts?: string[]; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" };
const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, status = "verified" } = Astro.props as Props; const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, sourceConflicts = [], status = "verified" } = Astro.props as Props;
const freshness = freshnessStatus(observedAt); const freshness = freshnessStatus(observedAt);
const displayStatus = status === "verified" && freshness === "stale" ? "stale" : status; const displayStatus = sourceConflicts.length ? "conflict" : status === "verified" && freshness === "stale" ? "stale" : status;
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", stale: "Данные устарели" }; const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" };
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`; const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }; const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" };
--- ---
<section class="data-passport" aria-label="Паспорт данных"> <section class="data-passport" aria-label="Паспорт данных">
<header><span>Паспорт данных</span><strong data-passport-status={displayStatus}>{statusLabels[displayStatus]}</strong></header> <header><span>Паспорт данных</span><strong data-passport-status={displayStatus}>{statusLabels[displayStatus]}</strong></header>
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div> <div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
{sourceConflicts.length > 0 && <p class="data-passport__conflict"><strong>Источники расходятся:</strong> {sourceConflicts.join(" · ")}</p>}
<dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</dd></div>}<div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{precisionLabels[coordinatePrecision]}</dd></div>}</dl> <dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</dd></div>}<div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{precisionLabels[coordinatePrecision]}</dd></div>}</dl>
{sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>} {sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>}
</section> </section>
+2 -2
View File
@@ -4,7 +4,7 @@ export type Activity = {
unique_players: number; average_weight_g: number; max_weight_g: number; unique_players: number; average_weight_g: number; max_weight_g: number;
last_confirmed_at: string; activity_score: number; confidence_score: number; last_confirmed_at: string; activity_score: number; confidence_score: number;
explanation: string; sources: string[]; explanation: string; sources: string[];
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[];
}; };
export type PaginatedActivity = { export type PaginatedActivity = {
@@ -14,7 +14,7 @@ export type PaginatedActivity = {
offset: number; offset: number;
}; };
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[] }; 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; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; 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; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
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 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 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[] };
+1 -1
View File
@@ -44,7 +44,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
<div class="section-heading"><h2>Последние уловы</h2></div> <div class="section-heading"><h2>Последние уловы</h2></div>
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>} {catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
</div> </div>
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} status={activity.catches < 3 ? "insufficient" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside> <aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} sourceConflicts={activity.source_conflicts} status={activity.catches < 3 ? "insufficient" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
</section> </section>
</>} </>}
</Layout> </Layout>
+1
View File
@@ -2,5 +2,6 @@
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"],.data-passport header strong[data-passport-status="insufficient"],.data-passport header strong[data-passport-status="blocked"],.data-passport header strong[data-passport-status="stale"]{background:var(--warning-soft);color:var(--warning)} .data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"],.data-passport header strong[data-passport-status="insufficient"],.data-passport header strong[data-passport-status="blocked"],.data-passport header strong[data-passport-status="stale"]{background:var(--warning-soft);color:var(--warning)}
.data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)} .data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)}
.data-passport__minimum{margin:12px 0 0;color:var(--warning);font-size:11px;line-height:1.4} .data-passport__minimum{margin:12px 0 0;color:var(--warning);font-size:11px;line-height:1.4}
.data-passport__conflict{margin:12px 0 0;padding:9px 10px;border-left:3px solid var(--warning);background:var(--warning-soft);color:var(--warning);font-size:11px;line-height:1.4}.data-passport__conflict strong{font-weight:750}
.detail-card .data-passport,.detail-grid aside .data-passport{border-color:#ffffff20;background:#ffffff08;color:#fff}.detail-card .data-passport dd,.detail-grid aside .data-passport dd{color:#fff}.detail-card .data-passport dl div,.detail-grid aside .data-passport dl div{border-color:#ffffff1c}.detail-card .data-passport dt,.detail-grid aside .data-passport dt{color:#a9b8b5}.signal-card .data-passport{position:relative;z-index:1;margin-top:15px;background:#fffdf7;border-color:#ddd3af} .detail-card .data-passport,.detail-grid aside .data-passport{border-color:#ffffff20;background:#ffffff08;color:#fff}.detail-card .data-passport dd,.detail-grid aside .data-passport dd{color:#fff}.detail-card .data-passport dl div,.detail-grid aside .data-passport dl div{border-color:#ffffff1c}.detail-card .data-passport dt,.detail-grid aside .data-passport dt{color:#a9b8b5}.signal-card .data-passport{position:relative;z-index:1;margin-top:15px;background:#fffdf7;border-color:#ddd3af}
@media(max-width:720px){.data-passport dl{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}} @media(max-width:720px){.data-passport dl{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
+1 -1
View File
@@ -94,7 +94,7 @@
- [ ] **U01 · UX-контракт и scorecard.** Контракт маршрутов, смысловой порядок ответа, словарь статусов и целевые метрики зафиксированы в [ux-contract.md](ux-contract.md); для home, spot, waterbody, plan и tackle описаны первый ответ и обязательное объяснение. Осталось провести ручной task-based review и собрать completion rate на пилоте. - [ ] **U01 · UX-контракт и scorecard.** Контракт маршрутов, смысловой порядок ответа, словарь статусов и целевые метрики зафиксированы в [ux-contract.md](ux-contract.md); для home, spot, waterbody, plan и tackle описаны первый ответ и обязательное объяснение. Осталось провести ручной task-based review и собрать completion rate на пилоте.
- [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Осталось добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры. - [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Осталось добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры.
- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, а ограниченный источник не смешивается с неполными полями. Targeted E2E и unit-тест проверяют публичные пути, период и 48-часовой порог. Осталось добавить конфликт источников. - [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников.
- [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу. - [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу.
- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт теперь проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; остаётся расширить сравнение подтверждёнными полями метода/риска. - [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт теперь проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; остаётся расширить сравнение подтверждёнными полями метода/риска.
- [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, малая выборка не маркируется как готовая рекомендация, а дублирующие catches/players/confidence убраны из вторичной колонки activity-карточки. Осталось провести ручной review на 5 ключевых маршрутах. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, малая выборка не маркируется как готовая рекомендация, а дублирующие catches/players/confidence убраны из вторичной колонки activity-карточки. Осталось провести ручной review на 5 ключевых маршрутах.
+1
View File
@@ -39,6 +39,7 @@ production-метрики.
| `temporary_error` | Данные временно недоступны | Не заменять последнюю дату свежей; дать понятный retry/следующий шаг | | `temporary_error` | Данные временно недоступны | Не заменять последнюю дату свежей; дать понятный retry/следующий шаг |
| `verified` | Учтено | Есть разрешённый источник и достаточный контекст | | `verified` | Учтено | Есть разрешённый источник и достаточный контекст |
| `incomplete` | Неполные данные | Перечислить отсутствующие поля, а не скрывать карточку за цветом | | `incomplete` | Неполные данные | Перечислить отсутствующие поля, а не скрывать карточку за цветом |
| `source_conflict` | Источники расходятся | Показать текст provenance-конфликта и не скрывать его за агрегированным рейтингом |
## Scorecard ## Scorecard