From 5a8174b11e3ab51bbf4d18a8b91537d0a58a0c2d Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 20 Sep 2026 20:21:39 +0700 Subject: [PATCH] feat: label stale evidence data --- apps/web/src/components/DataPassport.astro | 10 ++++++---- apps/web/src/lib/api.ts | 2 +- apps/web/src/lib/presentation.ts | 7 +++++++ apps/web/src/styles/data-passport.css | 2 +- apps/web/tests/unit/presentation.test.ts | 9 ++++++++- docs/ROADMAP.md | 4 ++-- 6 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/DataPassport.astro b/apps/web/src/components/DataPassport.astro index 9c9e7fb..4437004 100644 --- a/apps/web/src/components/DataPassport.astro +++ b/apps/web/src/components/DataPassport.astro @@ -1,14 +1,16 @@ --- import SourceBadge from "./SourceBadge.astro"; -import { ago } from "../lib/api"; +import { ago, freshnessStatus } from "../lib/api"; 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 freshness = freshnessStatus(observedAt); +const displayStatus = status === "verified" && freshness === "stale" ? "stale" : status; +const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", stale: "Данные устарели" }; const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`; const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }; ---
-
Паспорт данных{statusLabels[status]}
+
Паспорт данных{statusLabels[displayStatus]}
{sources.map(source => )}
-
Свежесть
{observedAt ? ago(observedAt) : "Не указана"}
Полнота
{completenessLabel}
Доверие
{confidence == null ? "После проверки" : `${confidence}%`}
{sampleSize != null &&
Наблюдения
{sampleSize}
}{independentPlayers != null &&
Игроки
{independentPlayers}
}{coordinatePrecision &&
Координаты
{precisionLabels[coordinatePrecision]}
}
+
Свежесть
{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}
Полнота
{completenessLabel}
Доверие
{confidence == null ? "После проверки" : `${confidence}%`}
{sampleSize != null &&
Наблюдения
{sampleSize}
}{independentPlayers != null &&
Игроки
{independentPlayers}
}{coordinatePrecision &&
Координаты
{precisionLabels[coordinatePrecision]}
}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 21b350a..dcf6ef3 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, kg, plural } from "./presentation"; +export { activityLevel, ago, freshnessStatus, kg, plural } 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 939ad07..ff3e81b 100644 --- a/apps/web/src/lib/presentation.ts +++ b/apps/web/src/lib/presentation.ts @@ -20,3 +20,10 @@ export function ago(value: string) { const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000)); return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`; } + +export function freshnessStatus(value: string | null | undefined, now = Date.now()): "fresh" | "stale" | "unknown" { + if (!value) return "unknown"; + const timestamp = new Date(value).getTime(); + if (!Number.isFinite(timestamp)) return "unknown"; + return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh"; +} diff --git a/apps/web/src/styles/data-passport.css b/apps/web/src/styles/data-passport.css index e5f3421..334b7ef 100644 --- a/apps/web/src/styles/data-passport.css +++ b/apps/web/src/styles/data-passport.css @@ -1,5 +1,5 @@ .data-passport{margin-top:18px;padding:14px;border:1px solid var(--border);border-radius:13px;background:var(--surface-soft);color:var(--deep)} -.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"]{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="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)} .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}} diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index a923cab..aad6492 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, plural } from "../../src/lib/presentation.ts"; +import { activityLevel, freshnessStatus, plural } 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"; @@ -23,6 +23,13 @@ test("Russian plural forms cover common and exceptional endings", () => { ); }); +test("freshness status is explicit and deterministic around the 48-hour boundary", () => { + const now = Date.parse("2026-09-20T12:00:00Z"); + assert.equal(freshnessStatus("2026-09-20T11:59:00Z", now), "fresh"); + assert.equal(freshnessStatus("2026-09-18T11:59:00Z", now), "stale"); + assert.equal(freshnessStatus(null, now), "unknown"); +}); + 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 ff26c42..33937ca 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -94,11 +94,11 @@ - [ ] **U01 · UX-контракт и scorecard.** Контракт маршрутов, смысловой порядок ответа, словарь статусов и целевые метрики зафиксированы в [ux-contract.md](ux-contract.md); для home, spot, waterbody, plan и tackle описаны первый ответ и обязательное объяснение. Осталось провести ручной task-based review и собрать completion rate на пилоте. - [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная уже сохраняет рыбу, водоём, период и сортировку в shareable URL и после поиска явно показывает контекст запроса, включая мобильный режим; empty-state предлагает вернуться к полному набору данных. Осталось показать 3–5 вариантов с понятным CTA и добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры. -- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness, completeness, confidence/статус, source badges и доступные доменные поля; targeted E2E проверяет публичные пути. Осталось добавить период выборки, конфликт источников и правило минимальной выборки для любых рекомендаций. Нельзя показывать «лучшую точку» или AI-like рекомендацию без объяснения и минимальной выборки. +- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, completeness, confidence/статус, source badges и доступные доменные поля; targeted E2E и unit-тест проверяют публичные пути и 48-часовой порог. Осталось добавить период выборки, конфликт источников и правило минимальной выборки для любых рекомендаций. Нельзя показывать «лучшую точку» или AI-like рекомендацию без объяснения и минимальной выборки. - [ ] **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 на 320/390 px и расширить сравнение полями метода/риска. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, не только цветом. Осталось сократить конкурирующие цифры на карточках и провести ручной review на 5 ключевых маршрутах. -- [ ] **U07 · UX-приёмка и измерения.** Playwright-контракты уже покрывают query journey, evidence states, saved plan, share/print, accessibility и visual matrix; их критерии собраны в [ux-contract.md](ux-contract.md). Осталось добавить отдельные stale/blocked/insufficient-data fixtures, сохранить 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-порог. Их критерии собраны в [ux-contract.md](ux-contract.md). Осталось добавить отдельные blocked/insufficient-data fixtures, сохранить 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 подключит эту реакцию к разрешённому плановому запросу.