diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ec93f91..4ac941c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -34,7 +34,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, plural } from "./presentation"; +export { activityLevel, ago, freshnessStatus, kg, 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 ff3e81b..67b3f34 100644 --- a/apps/web/src/lib/presentation.ts +++ b/apps/web/src/lib/presentation.ts @@ -27,3 +27,11 @@ export function freshnessStatus(value: string | null | undefined, now = Date.now if (!Number.isFinite(timestamp)) return "unknown"; return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh"; } + +export function sourceStatusLabel(status: string) { + const labels: Record = { + healthy: "Актуален", stale: "Данные устарели", temporarily_limited: "Источник временно ограничен", + source_changed: "Источник изменился", waiting: "Ожидает запуска", disabled: "Выключен", + }; + return labels[status] ?? "Состояние не определено"; +} diff --git a/apps/web/src/pages/status.astro b/apps/web/src/pages/status.astro index de0fc6d..cb15270 100644 --- a/apps/web/src/pages/status.astro +++ b/apps/web/src/pages/status.astro @@ -4,13 +4,12 @@ import SourceBadge from "../components/SourceBadge.astro"; import DataLegend from "../components/DataLegend.astro"; import PageHero from "../components/PageHero.astro"; import StatePanel from "../components/StatePanel.astro"; -import { ago, api, plural, type SourceStatus } from "../lib/api"; +import { ago, api, plural, sourceStatusLabel, type SourceStatus } from "../lib/api"; let sources: SourceStatus[] = [], unavailable = false; try { sources = await api("/api/v1/source-status"); } catch { unavailable = true; } -const labels = { healthy:"Актуален", stale:"Данные устарели", temporarily_limited:"Временная пауза", source_changed:"Источник изменился", waiting:"Ожидает запуска", disabled:"Выключен" }; --- - {unavailable ? :
{sources.map(source =>
{labels[source.status]}

{source.name}

{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}

{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}

)}
} + {unavailable ? :
{sources.map(source =>
{sourceStatusLabel(source.status)}

{source.name}

{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}

{source.status === "temporarily_limited" ? "Источник ограничен; опубликованные данные сохранены." : source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}

)}
}
diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index aad6492..74a8ffe 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, plural } from "../../src/lib/presentation.ts"; +import { activityLevel, freshnessStatus, 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"; @@ -30,6 +30,12 @@ test("freshness status is explicit and deterministic around the 48-hour boundary assert.equal(freshnessStatus(null, now), "unknown"); }); +test("source status makes a blocked provider explicit", () => { + assert.equal(sourceStatusLabel("temporarily_limited"), "Источник временно ограничен"); + assert.equal(sourceStatusLabel("healthy"), "Актуален"); + assert.equal(sourceStatusLabel("unexpected"), "Состояние не определено"); +}); + test("fish visual families follow body type instead of a name hash", () => { assert.deepEqual( ["Щука", "Озёрная форель", "Карп", "Окунь", "Сом", "Угорь", "Палтус", "Треска", "Неизвестная рыба"].map(fishVisualFamily), diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index de3f9f1..3bbbd59 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,7 +98,7 @@ - [ ] **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; карточка плана теперь сравнивает подтверждённые метод/проводку и явно показывает риск по выборке/уверенности. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, малая выборка не маркируется как готовая рекомендация, а дублирующие catches/players/confidence убраны из вторичной колонки activity-карточки. Осталось провести ручной review на 5 ключевых маршрутах. -- [ ] **U07 · UX-приёмка и измерения.** Playwright-контракты уже покрывают query journey, evidence states, saved plan, share/print, accessibility и visual matrix; unit-контракт добавил детерминированный stale-порог, а insufficient-data теперь отделён от incomplete в публичном паспорте. Их критерии собраны в [ux-contract.md](ux-contract.md). Осталось добавить отдельный blocked fixture, сохранить reference screenshots и получить production Lighthouse/CLS/INP/time-to-first-useful-answer после пилота. +- [ ] **U07 · UX-приёмка и измерения.** Playwright-контракты уже покрывают query journey, evidence states, saved plan, share/print, accessibility и visual matrix; unit-контракт добавил детерминированные stale- и blocked-подписи, а insufficient-data теперь отделён от incomplete в публичном паспорте. `/status` явно сообщает, что при ограничении источника опубликованные данные сохранены. Осталось сохранить reference screenshots и получить production Lighthouse/CLS/INP/time-to-first-useful-answer после пилота. - [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник. - [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу.