feat: display provenance and incomplete signals
This commit is contained in:
@@ -69,10 +69,22 @@ def activity_rows(
|
||||
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
||||
activity_score=activity, confidence_score=confidence,
|
||||
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
|
||||
sources=sorted({_source_system(item) for item in items}),
|
||||
))
|
||||
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
||||
|
||||
|
||||
def _source_system(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||
return str(provenance["source_system"])
|
||||
if report.source_type.value == "official_record":
|
||||
return "rf4-official"
|
||||
if report.source_type.value == "user":
|
||||
return "players"
|
||||
return "manual-import"
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
+46
-3
@@ -24,9 +24,9 @@ from .config import settings
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||
from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .models import Bait, BaitKind, CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -150,7 +150,50 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
||||
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
||||
_spot_or_404(db, spot_id)
|
||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
|
||||
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed, source_system=_report_source(r), source_url=r.source_url) for r in reports]
|
||||
|
||||
|
||||
def _report_source(report: CatchReport) -> str:
|
||||
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||
if isinstance(provenance, dict) and provenance.get("source_system"):
|
||||
return str(provenance["source_system"])
|
||||
if report.source_type == SourceType.official_record:
|
||||
return "rf4-official"
|
||||
if report.source_type == SourceType.user:
|
||||
return "players"
|
||||
return "manual-import"
|
||||
|
||||
|
||||
@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
||||
def community_observations(
|
||||
db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0),
|
||||
) -> list[PublicObservationOut]:
|
||||
items = list(db.scalars(
|
||||
select(ExternalObservation)
|
||||
.join(ExternalObservation.source)
|
||||
.options(joinedload(ExternalObservation.source))
|
||||
.where(
|
||||
ExternalObservation.catch_report_id.is_(None),
|
||||
ExternalObservation.status != "rejected",
|
||||
DataSource.enabled.is_(True),
|
||||
)
|
||||
.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc())
|
||||
.offset(offset).limit(limit)
|
||||
))
|
||||
result: list[PublicObservationOut] = []
|
||||
for item in items:
|
||||
missing = []
|
||||
if item.x is None or item.y is None:
|
||||
missing.append("координаты")
|
||||
if item.weight_g is None:
|
||||
missing.append("вес")
|
||||
result.append(PublicObservationOut(
|
||||
id=item.id, source_system=item.source_system, source_name=item.source.name,
|
||||
source_url=item.source_url, fish_name=item.fish_name, waterbody_name=item.waterbody_name,
|
||||
x=item.x, y=item.y, weight_g=item.weight_g, last_seen_at=item.last_seen_at,
|
||||
missing_fields=missing, quality="incomplete" if missing else "unverified",
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
||||
|
||||
@@ -47,6 +47,7 @@ class ActivityOut(BaseModel):
|
||||
activity_score: int
|
||||
confidence_score: int
|
||||
explanation: str
|
||||
sources: list[str]
|
||||
|
||||
|
||||
class CatchOut(BaseModel):
|
||||
@@ -59,6 +60,8 @@ class CatchOut(BaseModel):
|
||||
reported_at: datetime
|
||||
retrieve_method: str | None
|
||||
retrieve_speed: int | None
|
||||
source_system: str
|
||||
source_url: str | None
|
||||
|
||||
|
||||
class SpotOut(BaseModel):
|
||||
@@ -85,6 +88,22 @@ class OfficialRecordOut(BaseModel):
|
||||
category: str | None
|
||||
region: str | None
|
||||
source_url: str | None
|
||||
source_system: str = "rf4-official"
|
||||
|
||||
|
||||
class PublicObservationOut(BaseModel):
|
||||
id: UUID
|
||||
source_system: str
|
||||
source_name: str
|
||||
source_url: str
|
||||
fish_name: str
|
||||
waterbody_name: str
|
||||
x: int | None
|
||||
y: int | None
|
||||
weight_g: int | None
|
||||
last_seen_at: datetime
|
||||
missing_fields: list[str]
|
||||
quality: str
|
||||
|
||||
|
||||
class ImportRunOut(BaseModel):
|
||||
|
||||
@@ -49,6 +49,7 @@ def test_activity_filters_and_explains_score() -> None:
|
||||
assert payload[0]["catches"] == 3
|
||||
assert payload[0]["unique_players"] == 3
|
||||
assert "3 свежих улова" in payload[0]["explanation"]
|
||||
assert payload[0]["sources"] == ["manual-import"]
|
||||
|
||||
|
||||
def test_invalid_period_is_rejected() -> None:
|
||||
@@ -81,6 +82,7 @@ def test_spot_detail_and_catches() -> None:
|
||||
assert detail.json()["catches_24h"] == 3
|
||||
assert catches.status_code == 200
|
||||
assert len(catches.json()) == 3
|
||||
assert catches.json()[0]["source_system"] == "manual-import"
|
||||
|
||||
|
||||
def test_records_list_is_empty_before_import() -> None:
|
||||
@@ -164,7 +166,7 @@ def test_external_observation_requires_mapping_and_complete_data_before_publicat
|
||||
assert {alias.entity_type for alias in aliases} == {"fish", "waterbody"}
|
||||
|
||||
|
||||
def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
def test_incomplete_external_observation_is_publicly_labelled_but_not_counted() -> None:
|
||||
with Session(engine) as db:
|
||||
stage_observations(db, [{
|
||||
"source_system": "rf4db", "source_external_id": "review-incomplete",
|
||||
@@ -174,6 +176,13 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
observation_id = db.scalar(select(ExternalObservation.id).where(
|
||||
ExternalObservation.source_external_id == "review-incomplete"
|
||||
))
|
||||
public = client.get("/api/v1/community-observations")
|
||||
assert public.status_code == 200
|
||||
signal = next(item for item in public.json() if item["id"] == str(observation_id))
|
||||
assert signal["source_system"] == "rf4db"
|
||||
assert signal["quality"] == "incomplete"
|
||||
assert signal["missing_fields"] == ["вес"]
|
||||
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json())
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
mapped = client.patch(
|
||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
||||
@@ -186,6 +195,7 @@ def test_incomplete_external_observation_stays_out_of_public_data() -> None:
|
||||
json={"reason": "weight is absent"},
|
||||
)
|
||||
assert rejected.json()["status"] == "rejected"
|
||||
assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json())
|
||||
|
||||
|
||||
def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import type { Activity } from "../lib/api";
|
||||
import { activityLevel, ago, kg, plural } from "../lib/api";
|
||||
import FishingIcon from "./FishingIcon.astro";
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
const { item } = Astro.props as { item: Activity };
|
||||
const level = activityLevel(item.activity_score);
|
||||
const limited = item.catches < 3;
|
||||
---
|
||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
|
||||
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
||||
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div><p class="data-note">{item.explanation}</p><div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
|
||||
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div><div class="source-strip" aria-label="Источники данных">{item.sources.map(source => <SourceBadge source={source}/>)}</div><p class="data-note">{item.explanation}</p><div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</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>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
const { source, href, tone = "source" } = Astro.props as { source: string; href?: string | null; tone?: "source" | "incomplete" | "verified" };
|
||||
const labels: Record<string, string> = {
|
||||
"rf4-official": "RF4 · официальный",
|
||||
rf4db: "RF4DB",
|
||||
"rf4stat-fishing": "RF4-STAT · уловы",
|
||||
"rf4stat-post": "RF4-STAT · посты",
|
||||
rf4map: "RF4MAP",
|
||||
"rf4posts-spot": "RF4 Posts",
|
||||
players: "Игроки RF4",
|
||||
"manual-import": "Архивный импорт",
|
||||
};
|
||||
const label = labels[source] ?? source;
|
||||
const mark = source === "players" ? "♟" : source === "rf4-official" ? "★" : "↗";
|
||||
---
|
||||
{href ? <a class="source-chip" data-source={source} data-tone={tone} href={href} target="_blank" rel="noreferrer" title={`Открыть источник: ${label}`}><i>{mark}</i><span>{label}</span></a> : <span class="source-chip" data-source={source} data-tone={tone}><i>{mark}</i><span>{label}</span></span>}
|
||||
@@ -3,13 +3,14 @@ export type Activity = {
|
||||
fish: string; x: number; y: number; best_bait: string | null; catches: number;
|
||||
unique_players: number; average_weight_g: number; max_weight_g: number;
|
||||
last_confirmed_at: string; activity_score: number; confidence_score: number;
|
||||
explanation: string;
|
||||
explanation: string; 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[] };
|
||||
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 };
|
||||
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 };
|
||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
|
||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
||||
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||
|
||||
export { activityLevel, ago, kg, plural } from "./presentation";
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ActivityCard from "../components/ActivityCard.astro";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem } from "../lib/api";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PublicObservation } from "../lib/api";
|
||||
|
||||
const params = Astro.url.searchParams;
|
||||
const hours = params.get("hours") ?? "24";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
const fish = params.get("fish") ?? "";
|
||||
const sort = params.get("sort") ?? "activity";
|
||||
let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
|
||||
let unavailable = false;
|
||||
try {
|
||||
[fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
|
||||
[fishes, waterbodies, signals] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>("/api/v1/community-observations?limit=12")]);
|
||||
if (!filterError) {
|
||||
const query = new URLSearchParams({ hours, waterbody, fish, sort });
|
||||
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
||||
@@ -39,8 +40,9 @@ const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "
|
||||
</form></section>
|
||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</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} {plural(items.length, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></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="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} 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><FishingIcon name="lure" size={25}/><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>}
|
||||
{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="float-gauge" style={`--level:${items[0].activity_score}%`} 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><FishingIcon name="lure" size={25}/><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>
|
||||
{signals.length > 0 && <section class="signal-section content-grid" aria-labelledby="signals-title"><div class="signal-heading"><div><span class="overline">Сырые данные источников</span><h2 id="signals-title">Полевые сигналы</h2><p>Показываем сразу, даже если часть полей отсутствует. Эти карточки не участвуют в расчёте активности до полного подтверждения.</p></div><span class="signal-count">{signals.length} новых</span></div><div class="signal-grid">{signals.map(signal => <article class="signal-card" data-quality={signal.quality}><div class="signal-card__top"><SourceBadge source={signal.source_system} href={signal.source_url}/><span class="quality-chip"><i>{signal.quality === "incomplete" ? "!" : "?"}</i> {signal.quality === "incomplete" ? "Неполные данные" : "Ждёт проверки"}</span></div><h3>{signal.fish_name}</h3><p>{signal.waterbody_name}</p><dl><div><dt>Точка</dt><dd>{signal.x == null || signal.y == null ? "не указана" : `${signal.x}:${signal.y}`}</dd></div><div><dt>Вес</dt><dd>{signal.weight_g == null ? "не указан" : kg(signal.weight_g)}</dd></div><div><dt>Получено</dt><dd>{ago(signal.last_seen_at)}</dd></div></dl><p class="missing-note"><span>{signal.missing_fields.length ? "Не хватает:" : "Статус:"}</span> {signal.missing_fields.join(", ") || "проверяется соответствие справочнику"}</p></article>)}</div></section>}
|
||||
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
|
||||
<script>
|
||||
const advancedFilters = document.querySelector<HTMLDetailsElement>(".filter-advanced");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api";
|
||||
const params = Astro.url.searchParams;
|
||||
const fish = params.get("fish") ?? "";
|
||||
@@ -12,6 +13,6 @@ const last = runs[0];
|
||||
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
|
||||
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
|
||||
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
|
||||
{unavailable ? <div class="state content-grid"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><time data-label="Дата">{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Для выбранных условий записей пока нет.</p></div>}
|
||||
{unavailable ? <div class="state content-grid"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{records.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Для выбранных условий записей пока нет.</p></div>}
|
||||
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||
</Layout>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import SourceBadge from "../../components/SourceBadge.astro";
|
||||
import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||
const { id } = Astro.params;
|
||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||
@@ -14,6 +15,6 @@ const level = activity ? activityLevel(activity.activity_score) : null;
|
||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
|
||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></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><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></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><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||
</>}
|
||||
</Layout>
|
||||
|
||||
@@ -28,3 +28,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
/* WCAG AA secondary text on light surfaces */
|
||||
.eyebrow,.overline,.result-count,.spot-rank,.spot-topline,.spot-meta,.bait-line div span,.spot-stats span,.data-note,.state,.principles p,.principles article>span,.official-note,.privacy,.field-help,.report-form legend span,.optional-fields summary span,.record-row>span,.record-row>time{color:#4d605d}
|
||||
.consent{display:flex!important;align-items:flex-start;gap:10px;margin:20px 0;text-transform:none!important;letter-spacing:0!important;font-size:14px!important;line-height:1.45}.consent input{width:20px;height:20px;margin:0;flex:none}.legal-page{width:min(760px,calc(100% - 32px));margin:60px auto 100px}.legal-page h1{font:400 clamp(48px,7vw,82px)/.95 Georgia,serif;letter-spacing:-.05em}.legal-page h2{margin-top:36px;font:400 28px Georgia,serif}.legal-page p,.legal-page li{color:#405552;line-height:1.7}.legal-page a{text-underline-offset:3px}
|
||||
|
||||
/* Provenance badges and immediate incomplete-data feed */
|
||||
.source-strip{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.source-chip{--chip:#315f63;display:inline-flex!important;grid-template-columns:none!important;align-items:center;gap:6px;width:max-content;padding:4px 9px 4px 5px;border:1px solid color-mix(in srgb,var(--chip) 34%,transparent);border-radius:999px;background:color-mix(in srgb,var(--chip) 10%,#fff);color:var(--chip)!important;font:750 10px/1.2 Inter,sans-serif!important;letter-spacing:.025em;text-decoration:none;text-transform:none;white-space:nowrap}.source-chip i{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:var(--chip);color:#fff;font:700 9px/1 Inter,sans-serif}.source-chip[href]{transition:transform .18s ease,box-shadow .18s ease}.source-chip[href]:hover{transform:translateY(-1px);box-shadow:0 5px 14px #102f3220}.source-chip[data-source="rf4-official"]{--chip:#9a6a22}.source-chip[data-source="rf4db"]{--chip:#306f8f}.source-chip[data-source^="rf4stat"]{--chip:#6951a3}.source-chip[data-source="rf4map"]{--chip:#36785a}.source-chip[data-source="rf4posts-spot"]{--chip:#b05a3e}.source-chip[data-source="players"]{--chip:#55701d}.detail-card .source-chip{background:#ffffff0b;color:#e9f1ee!important;border-color:#ffffff2b}.record-provenance{display:flex;flex-direction:column;align-items:flex-start;gap:7px}.record-provenance .source-chip:before{content:none!important}.catch-list .source-chip{margin-top:7px}
|
||||
.signal-section{padding:0 0 100px}.signal-heading{display:flex;align-items:end;justify-content:space-between;gap:35px;padding-top:68px;border-top:1px solid #cbd6ce;margin-bottom:25px}.signal-heading h2{font:400 46px/.95 Georgia,serif;letter-spacing:-.045em;margin:8px 0 10px}.signal-heading p{max-width:700px;color:#4d605d;line-height:1.55;margin:0}.signal-count{padding:7px 12px;border:1px solid #c2cec5;border-radius:999px;color:#526662;font-size:12px;white-space:nowrap}.signal-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}.signal-card{position:relative;overflow:hidden;padding:20px;border:1px solid #d6cda9;border-radius:15px;background:linear-gradient(145deg,#fffdf5,#f8f5e9);box-shadow:0 10px 25px #443d1a0a}.signal-card:after{content:"";position:absolute;width:80px;height:80px;right:-40px;bottom:-40px;border:1px solid #bdac6b55;border-radius:50%;box-shadow:0 0 0 13px #bdac6b14}.signal-card__top{display:flex;justify-content:space-between;align-items:center;gap:8px}.quality-chip{display:inline-flex;align-items:center;gap:5px;color:#76570c;font-size:10px;font-weight:750}.quality-chip i{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:#f2d77e;font-style:normal}.signal-card h3{font:400 24px Georgia,serif;margin:17px 0 3px}.signal-card>p{margin:0;color:#576966;font-size:13px}.signal-card dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0}.signal-card dl div{padding-top:9px;border-top:1px solid #d9d3b9}.signal-card dt{color:#776f56;font-size:9px;text-transform:uppercase;letter-spacing:.08em}.signal-card dd{margin:4px 0 0;font:400 14px Georgia,serif}.signal-card .missing-note{position:relative;z-index:1;padding:9px 11px;border-radius:8px;background:#efe5bd;color:#6b550f;font-size:11px}.missing-note span{font-weight:800}
|
||||
@media(max-width:1040px){.signal-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:720px){.signal-heading{display:block;padding-top:45px}.signal-count{display:inline-block;margin-top:14px}.signal-grid{grid-template-columns:1fr}.signal-card__top{align-items:flex-start}.source-strip{margin-bottom:4px}}
|
||||
|
||||
@@ -12,6 +12,7 @@ test("production bootstrap supports submission and moderation", async ({ page, r
|
||||
await page.getByLabel("Координата Y *").fill(String(y));
|
||||
await page.getByLabel("Вес, граммы *").fill("4321");
|
||||
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
||||
await page.getByText("Дополнительные сведения").click();
|
||||
await page.getByLabel("Ник игрока").fill(player);
|
||||
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||
await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible();
|
||||
|
||||
@@ -6,11 +6,13 @@ test("player can open an active spot", async ({ page }) => {
|
||||
const activeSpots = page.locator(".spot-card[data-testid]");
|
||||
await expect(activeSpots).not.toHaveCount(0);
|
||||
const activeSpot = activeSpots.first();
|
||||
await expect(activeSpot.locator(".source-chip")).not.toHaveCount(0);
|
||||
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
|
||||
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
||||
await activeSpot.click();
|
||||
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
||||
await expect(page.locator(".catch-list .source-chip")).not.toHaveCount(0);
|
||||
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
||||
});
|
||||
|
||||
@@ -24,6 +26,7 @@ test("submitted catch appears publicly only after moderation", async ({ page })
|
||||
await page.getByLabel("Координата X *").fill(String(x));
|
||||
await page.getByLabel("Координата Y *").fill(String(y));
|
||||
await page.getByLabel("Вес, граммы *").fill("6789");
|
||||
await page.getByText("Дополнительные сведения").click();
|
||||
await page.getByLabel("Ник игрока").fill(player);
|
||||
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
||||
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||
|
||||
Reference in New Issue
Block a user