diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index 3dec811..5663b45 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -1,6 +1,6 @@ --- import type { Activity } from "../lib/api"; -import { activityLevel, ago, kg, spotPath } from "../lib/api"; +import { activityLevel, ago, coordinatePrecisionLabel, kg, spotPath } from "../lib/api"; import FishingIcon from "./FishingIcon.astro"; import DataPassport from "./DataPassport.astro"; import FishSilhouette from "./FishSilhouette.astro"; @@ -8,7 +8,7 @@ import TackleGlyph from "./TackleGlyph.astro"; const { item, periodLabel = null } = Astro.props as { item: Activity; periodLabel?: string | null }; const level = activityLevel(item.activity_score); const limited = item.catches < 3; -const precision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }[item.coordinate_precision]; +const precision = coordinatePrecisionLabel(item.coordinate_precision); --- {String(item.activity_score).padStart(2,"0")} diff --git a/apps/web/src/components/DataPassport.astro b/apps/web/src/components/DataPassport.astro index 65486e9..253ca91 100644 --- a/apps/web/src/components/DataPassport.astro +++ b/apps/web/src/components/DataPassport.astro @@ -1,18 +1,17 @@ --- import SourceBadge from "./SourceBadge.astro"; -import { ago, freshnessStatus, passportDisplayStatus } from "../lib/api"; +import { ago, coordinatePrecisionLabel, freshnessStatus, passportDisplayStatus } 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; sourceConflicts?: string[]; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" }; 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 displayStatus = passportDisplayStatus(status, freshness, sourceConflicts.length > 0); const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" }; const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`; -const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }; ---
Паспорт данных{statusLabels[displayStatus]}
{sources.map(source => )}
{sourceConflicts.length > 0 &&

Источники расходятся: {sourceConflicts.join(" · ")}

} -
Свежесть
{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}
{periodLabel &&
Период
{periodLabel}
}
Полнота
{completenessLabel}
Доверие
{confidence == null ? "После проверки" : `${confidence}%`}
{sampleSize != null &&
Наблюдения
{sampleSize}
}{independentPlayers != null &&
Игроки
{independentPlayers}
}{coordinatePrecision &&
Координаты
{precisionLabels[coordinatePrecision]}
}
+
Свежесть
{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}
{periodLabel &&
Период
{periodLabel}
}
Полнота
{completenessLabel}
Доверие
{confidence == null ? "После проверки" : `${confidence}%`}
{sampleSize != null &&
Наблюдения
{sampleSize}
}{independentPlayers != null &&
Игроки
{independentPlayers}
}{coordinatePrecision &&
Координаты
{coordinatePrecisionLabel(coordinatePrecision)}
}
{sampleSize != null && sampleSize < 3 &&

Минимум для рекомендации: 3 наблюдения.

}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2efa8da..d57c94f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -37,7 +37,7 @@ export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tack export const spotPath = (item: Pick) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`; -export { activityLevel, ago, freshnessStatus, kg, passportDisplayStatus, plural, sourceStatusLabel } from "./presentation"; +export { activityLevel, ago, coordinatePrecisionLabel, freshnessStatus, kg, passportDisplayStatus, plural, sourceStatusLabel } from "./presentation"; const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000"; diff --git a/apps/web/src/lib/presentation.ts b/apps/web/src/lib/presentation.ts index 7289a7c..a649097 100644 --- a/apps/web/src/lib/presentation.ts +++ b/apps/web/src/lib/presentation.ts @@ -28,6 +28,12 @@ export function freshnessStatus(value: string | null | undefined, now = Date.now return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh"; } +export type CoordinatePrecision = "exact" | "approximate" | "area" | "missing"; + +export function coordinatePrecisionLabel(value: CoordinatePrecision | string | null | undefined): string { + return ({ exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as Record)[value ?? ""] ?? "не указаны"; +} + export type PassportStatus = "verified" | "unverified" | "incomplete" | "insufficient" | "blocked"; export type PassportDisplayStatus = PassportStatus | "conflict" | "stale"; diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index 54e8287..637bee9 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -6,7 +6,7 @@ import TackleGlyph from "../../components/TackleGlyph.astro"; import ActivityTimeline from "../../components/ActivityTimeline.astro"; import CatchList from "../../components/CatchList.astro"; import DataPassport from "../../components/DataPassport.astro"; -import { activityLevel, api, ApiError, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api"; +import { activityLevel, api, ApiError, coordinatePrecisionLabel, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api"; const { id } = Astro.params; let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false; let timeline: { start: string; end: string; count: number }[] = []; @@ -25,7 +25,6 @@ try { if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60"); } const level = activity ? activityLevel(activity.activity_score) : null; -const coordinatePrecision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as const; const methodLabels: Record = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" }; const method = catches.map(item => item.fishing_method).find(Boolean) ?? ""; const retrieve = catches.map(item => item.retrieve_method).find(Boolean) ?? ""; @@ -39,7 +38,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr {unavailable || !spot ?

Точка недоступна

API не ответил или такой точки нет.

: <> -
{spot.waterbody}

Точка {spot.x}:{spot.y}

{spot.description}

Точность координат: {coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}

+
{spot.waterbody}

Точка {spot.x}:{spot.y}

{spot.description}

Точность координат: {coordinatePrecisionLabel(spot.coordinate_precision)}

{spot.catches_24h}за 24 часа
{spot.catches_3d}за 3 дня
{spot.catches_7d}за 7 дней
ТихоЕсть сигналыГорячо
diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index dc0b5cb..477d313 100644 --- a/apps/web/tests/unit/presentation.test.ts +++ b/apps/web/tests/unit/presentation.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { activityLevel, freshnessStatus, passportDisplayStatus, plural, sourceStatusLabel } from "../../src/lib/presentation.ts"; +import { activityLevel, coordinatePrecisionLabel, freshnessStatus, passportDisplayStatus, plural, sourceStatusLabel } from "../../src/lib/presentation.ts"; import { fishVisualFamily } from "../../src/lib/fish-visuals.ts"; import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts"; import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts"; @@ -38,6 +38,14 @@ test("evidence passport prioritises conflict and stale states without hiding blo assert.equal(passportDisplayStatus("incomplete", "fresh", true), "conflict"); }); +test("coordinate precision keeps all four public states explicit", () => { + assert.deepEqual( + ["exact", "approximate", "area", "missing"].map(coordinatePrecisionLabel), + ["точные", "приблизительные", "район", "не указаны"], + ); + assert.equal(coordinatePrecisionLabel("future"), "не указаны"); +}); + test("source status makes a blocked provider explicit", () => { assert.equal(sourceStatusLabel("temporarily_limited"), "Источник временно ограничен"); assert.equal(sourceStatusLabel("healthy"), "Актуален"); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 12ea4a3..c035042 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,7 +54,7 @@ - [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентный upsert применён к canonical snapshot: `19/19`, без missing/duplicate/provenance issues. Осталось применить подтверждённые detail snapshots и отдельно разделить игровые и редакционные тексты при подключении detail-данных. - [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника. - [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. RF4MAP/RF4-STAT directory parsers теперь преобразуются в crosswalk identities без переноса метрик или автоматической привязки. Осталось подать полный набор реальных RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта. -- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow; browser regression для отсутствующей точки проверяет недоступное состояние без ложных координат, а API regression параметризован по всем четырём precision-состояниям. Осталась полная browser QA матрица для всех precision/error-состояний. +- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. Все public-компоненты используют единый `coordinatePrecisionLabel` с безопасным fallback и unit-регрессией четырёх состояний. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow; browser regression для отсутствующей точки проверяет недоступное состояние без ложных координат, а API regression параметризован по всем четырём precision-состояниям. Осталась полная browser QA матрица для всех precision/error-состояний. - [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Public media API теперь передаёт reviewed `media_role` и дополнительно отбрасывает unknown/cross-entity роли на границе публикации, waterbody detail принимает только `waterbody_cover`/`waterbody_map`/`waterbody_depth_map`, а UI подписывает карту/заставку/скриншот текстом. Осталось завершить browser QA для реальных role-вариантов и не показывать abstract fingerprint как внешнее изображение. - [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью, not-found waterbody и not-found waterbody/fish pair, а spot regression — unavailable state без ложной координаты; каталоги, media, analytics и tackle detail теперь имеют opt-in regression (`EXPECT_UNAVAILABLE=1`) для `503` + `Retry-After` + `no-store` без утечки внутренних ошибок. Полный web E2E и visual-matrix остаются обязательным финальным прогоном; внешние detail-данные закрываются отдельно. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.