feat: show evidence analysis periods
This commit is contained in:
@@ -5,7 +5,7 @@ import FishingIcon from "./FishingIcon.astro";
|
||||
import DataPassport from "./DataPassport.astro";
|
||||
import FishSilhouette from "./FishSilhouette.astro";
|
||||
import TackleGlyph from "./TackleGlyph.astro";
|
||||
const { item } = Astro.props as { item: Activity };
|
||||
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];
|
||||
@@ -18,7 +18,7 @@ const precision = { exact: "точные", approximate: "приблизител
|
||||
<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>
|
||||
<p class="data-note">{item.explanation}</p>
|
||||
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} status={limited ? "incomplete" : "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} status={limited ? "incomplete" : "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>
|
||||
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
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;
|
||||
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" };
|
||||
const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, status = "verified" } = Astro.props as Props;
|
||||
const freshness = freshnessStatus(observedAt);
|
||||
const displayStatus = status === "verified" && freshness === "stale" ? "stale" : status;
|
||||
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", stale: "Данные устарели" };
|
||||
@@ -12,6 +12,6 @@ const precisionLabels = { exact: "точные", approximate: "приблизи
|
||||
<section class="data-passport" aria-label="Паспорт данных">
|
||||
<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>
|
||||
<dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</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>}
|
||||
</section>
|
||||
|
||||
@@ -30,5 +30,5 @@ const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage
|
||||
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${fish!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Визуальный справочник</span><h2>{fish!.name_ru}</h2><p>Изображение опубликовано с прямой ссылкой на источник. Оно помогает отличить вид, а актуальные точки ниже остаются отдельными наблюдениями игроков.</p></div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel="72 часа"/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -94,7 +94,7 @@ const datasetJsonLd = {
|
||||
<button data-action="primary">⌕ Найти клёв</button>
|
||||
</form></section>
|
||||
<section class="query-summary content-grid" aria-label="Контекст запроса" aria-live="polite"><div><span class="overline">Ваш запрос</span><h2 id="query-summary-title">{queryTitle}</h2><p>{periodLabel} · {sortLabel}</p></div><div class="query-summary__filters" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить запрос</a>}</div></section>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} на странице · всего {totalItems}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p>{filtersChanged && <a data-action="secondary" href="/#results">Показать все данные</a>}</div>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} на странице · всего {totalItems}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} periodLabel={periodLabel} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p>{filtersChanged && <a data-action="secondary" href="/#results">Показать все данные</a>}</div>}</div>
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
</section>
|
||||
{!filterError && !activityUnavailable && <Pagination path="/" params={params} total={totalItems} limit={activityLimit} offset={offset} anchor="#results" itemLabel="точек" />}
|
||||
|
||||
@@ -33,5 +33,5 @@ const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPag
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
||||
{water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) && <section class="data-passport content-grid" aria-label="Паспорт водоёма"><div><span class="overline">Канонические сведения</span><h2>{water.name_ru}</h2>{water.description && <p>{water.description}</p>}{water.unlock_level !== undefined && water.unlock_level !== null && <p>Открывается с уровня: <strong>{water.unlock_level}</strong></p>}{water.source_fish_species?.length && <p>В карточке источника указано видов рыб: <strong>{water.source_fish_species.length}</strong>.</p>}{water.source_aliases?.length && <p>Алиасы источника: {water.source_aliases.join(", ")}.</p>}</div><div><span class="overline">Источник</span>{water.source_url ? <p><a href={water.source_url} rel="noreferrer">{water.source_system ?? "Внешний источник"} · исходная карточка</a></p> : <p>Источник для описания ещё не подтверждён.</p>}{water.source_point_urls?.length && <p>Сохранено ссылок на точки: <strong>{water.source_point_urls.length}</strong>.</p>}{water.source_image_urls?.length && <p>Изображений-кандидатов: <strong>{water.source_image_urls.length}</strong>; публикация требует отдельной проверки.</p>}{water.source_checked_at && <p>Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}</p>}</div></section>}
|
||||
{water && <DataPassport sources={water.source_system ? [water.source_system] : []} sourceUrl={water.source_url} observedAt={water.source_checked_at} status={water.source_checked_at ? "verified" : "unverified"}/>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel="72 часа"/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -24,5 +24,5 @@ const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на $
|
||||
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено", href: water ? `/waterbodies/${water.slug}` : undefined }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${items.length} ${plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel="72 часа"/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -11,6 +11,7 @@ test("activity cards explain freshness, sample size and coordinate precision", a
|
||||
await expect(first).toContainText("Наблюдения");
|
||||
await expect(first).toContainText("Игроки");
|
||||
await expect(first).toContainText("Координаты");
|
||||
await expect(first).toContainText("Период");
|
||||
if (await first.locator('[data-passport-status="incomplete"]').count()) {
|
||||
await expect(first).toContainText("Минимум для рекомендации: 3 наблюдения");
|
||||
}
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@
|
||||
|
||||
- [ ] **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 и доступные доменные поля; при выборке меньше 3 явно показан минимум для рекомендации. Targeted E2E и unit-тест проверяют публичные пути и 48-часовой порог. Осталось добавить период выборки и конфликт источников.
|
||||
- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показан минимум для рекомендации. Targeted E2E и unit-тест проверяют публичные пути, период и 48-часовой порог. Осталось добавить конфликт источников.
|
||||
- [ ] **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 ключевых маршрутах.
|
||||
|
||||
Reference in New Issue
Block a user