feat: display provenance and incomplete signals
This commit is contained in:
@@ -10,6 +10,8 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||||
|
|
||||||
|
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||||
|
|
||||||
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
||||||
|
|||||||
@@ -69,10 +69,22 @@ def activity_rows(
|
|||||||
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
||||||
activity_score=activity, confidence_score=confidence,
|
activity_score=activity, confidence_score=confidence,
|
||||||
explanation=_explanation(len(items), len(players), freshness_text, activity, 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)
|
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:
|
def _aware(value: datetime) -> datetime:
|
||||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
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 .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||||
from .logging_config import configure_logging
|
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 .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
|
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]:
|
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)
|
_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)))
|
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])
|
@app.get("/api/v1/records", response_model=list[OfficialRecordOut])
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class ActivityOut(BaseModel):
|
|||||||
activity_score: int
|
activity_score: int
|
||||||
confidence_score: int
|
confidence_score: int
|
||||||
explanation: str
|
explanation: str
|
||||||
|
sources: list[str]
|
||||||
|
|
||||||
|
|
||||||
class CatchOut(BaseModel):
|
class CatchOut(BaseModel):
|
||||||
@@ -59,6 +60,8 @@ class CatchOut(BaseModel):
|
|||||||
reported_at: datetime
|
reported_at: datetime
|
||||||
retrieve_method: str | None
|
retrieve_method: str | None
|
||||||
retrieve_speed: int | None
|
retrieve_speed: int | None
|
||||||
|
source_system: str
|
||||||
|
source_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class SpotOut(BaseModel):
|
class SpotOut(BaseModel):
|
||||||
@@ -85,6 +88,22 @@ class OfficialRecordOut(BaseModel):
|
|||||||
category: str | None
|
category: str | None
|
||||||
region: str | None
|
region: str | None
|
||||||
source_url: 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):
|
class ImportRunOut(BaseModel):
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def test_activity_filters_and_explains_score() -> None:
|
|||||||
assert payload[0]["catches"] == 3
|
assert payload[0]["catches"] == 3
|
||||||
assert payload[0]["unique_players"] == 3
|
assert payload[0]["unique_players"] == 3
|
||||||
assert "3 свежих улова" in payload[0]["explanation"]
|
assert "3 свежих улова" in payload[0]["explanation"]
|
||||||
|
assert payload[0]["sources"] == ["manual-import"]
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_period_is_rejected() -> None:
|
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 detail.json()["catches_24h"] == 3
|
||||||
assert catches.status_code == 200
|
assert catches.status_code == 200
|
||||||
assert len(catches.json()) == 3
|
assert len(catches.json()) == 3
|
||||||
|
assert catches.json()[0]["source_system"] == "manual-import"
|
||||||
|
|
||||||
|
|
||||||
def test_records_list_is_empty_before_import() -> None:
|
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"}
|
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:
|
with Session(engine) as db:
|
||||||
stage_observations(db, [{
|
stage_observations(db, [{
|
||||||
"source_system": "rf4db", "source_external_id": "review-incomplete",
|
"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(
|
observation_id = db.scalar(select(ExternalObservation.id).where(
|
||||||
ExternalObservation.source_external_id == "review-incomplete"
|
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"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
mapped = client.patch(
|
mapped = client.patch(
|
||||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
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"},
|
json={"reason": "weight is absent"},
|
||||||
)
|
)
|
||||||
assert rejected.json()["status"] == "rejected"
|
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:
|
def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import type { Activity } from "../lib/api";
|
import type { Activity } from "../lib/api";
|
||||||
import { activityLevel, ago, kg, plural } from "../lib/api";
|
import { activityLevel, ago, kg, plural } from "../lib/api";
|
||||||
import FishingIcon from "./FishingIcon.astro";
|
import FishingIcon from "./FishingIcon.astro";
|
||||||
|
import SourceBadge from "./SourceBadge.astro";
|
||||||
const { item } = Astro.props as { item: Activity };
|
const { item } = Astro.props as { item: Activity };
|
||||||
const level = activityLevel(item.activity_score);
|
const level = activityLevel(item.activity_score);
|
||||||
const limited = item.catches < 3;
|
const limited = item.catches < 3;
|
||||||
---
|
---
|
||||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
|
<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>
|
<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>
|
<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>
|
</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;
|
fish: string; x: number; y: number; best_bait: string | null; catches: number;
|
||||||
unique_players: number; average_weight_g: number; max_weight_g: number;
|
unique_players: number; average_weight_g: number; max_weight_g: number;
|
||||||
last_confirmed_at: string; activity_score: number; confidence_score: 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 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 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 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";
|
export { activityLevel, ago, kg, plural } from "./presentation";
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import ActivityCard from "../components/ActivityCard.astro";
|
import ActivityCard from "../components/ActivityCard.astro";
|
||||||
import FishingIcon from "../components/FishingIcon.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 params = Astro.url.searchParams;
|
||||||
const hours = params.get("hours") ?? "24";
|
const hours = params.get("hours") ?? "24";
|
||||||
const waterbody = params.get("waterbody") ?? "";
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const sort = params.get("sort") ?? "activity";
|
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);
|
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
|
||||||
let unavailable = false;
|
let unavailable = false;
|
||||||
try {
|
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) {
|
if (!filterError) {
|
||||||
const query = new URLSearchParams({ hours, waterbody, fish, sort });
|
const query = new URLSearchParams({ hours, waterbody, fish, sort });
|
||||||
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
||||||
@@ -39,8 +40,9 @@ const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "
|
|||||||
</form></section>
|
</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>
|
<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>
|
<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>
|
</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>
|
<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>
|
<script>
|
||||||
const advancedFilters = document.querySelector<HTMLDetailsElement>(".filter-advanced");
|
const advancedFilters = document.querySelector<HTMLDetailsElement>(".filter-advanced");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import SourceBadge from "../components/SourceBadge.astro";
|
||||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api";
|
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api";
|
||||||
const params = Astro.url.searchParams;
|
const params = Astro.url.searchParams;
|
||||||
const fish = params.get("fish") ?? "";
|
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>
|
<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>
|
<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>
|
<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>
|
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro";
|
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";
|
import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
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> : <>
|
{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>
|
<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>
|
<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>
|
</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 */
|
/* 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}
|
.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}
|
.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("Координата Y *").fill(String(y));
|
||||||
await page.getByLabel("Вес, граммы *").fill("4321");
|
await page.getByLabel("Вес, граммы *").fill("4321");
|
||||||
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
||||||
|
await page.getByText("Дополнительные сведения").click();
|
||||||
await page.getByLabel("Ник игрока").fill(player);
|
await page.getByLabel("Ник игрока").fill(player);
|
||||||
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||||
await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible();
|
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]");
|
const activeSpots = page.locator(".spot-card[data-testid]");
|
||||||
await expect(activeSpots).not.toHaveCount(0);
|
await expect(activeSpots).not.toHaveCount(0);
|
||||||
const activeSpot = activeSpots.first();
|
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");
|
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 expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
||||||
await activeSpot.click();
|
await activeSpot.click();
|
||||||
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
|
||||||
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 ?? "");
|
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("Координата X *").fill(String(x));
|
||||||
await page.getByLabel("Координата Y *").fill(String(y));
|
await page.getByLabel("Координата Y *").fill(String(y));
|
||||||
await page.getByLabel("Вес, граммы *").fill("6789");
|
await page.getByLabel("Вес, граммы *").fill("6789");
|
||||||
|
await page.getByText("Дополнительные сведения").click();
|
||||||
await page.getByLabel("Ник игрока").fill(player);
|
await page.getByLabel("Ник игрока").fill(player);
|
||||||
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
await page.getByLabel(/Я отправляю собственное наблюдение/).check();
|
||||||
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
- [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации.
|
- [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации.
|
||||||
- [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`.
|
- [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`.
|
||||||
- [x] Включить все пять разрешённых community-источников и автоматическую публикацию полных наблюдений по ранее подтверждённым алиасам без межисточникового склеивания (миграция `0012`, 7 сентября 2026).
|
- [x] Включить все пять разрешённых community-источников и автоматическую публикацию полных наблюдений по ранее подтверждённым алиасам без межисточникового склеивания (миграция `0012`, 7 сентября 2026).
|
||||||
|
- [x] Добавить обязательную публичную атрибуцию записей и агрегатов, а также отдельную ленту неполных community-наблюдений с явным перечнем отсутствующих полей без влияния на индекс активности (7 сентября 2026).
|
||||||
- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`.
|
- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`.
|
||||||
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||||
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- Разрешённый технический контур: получение публичного HTML, извлечение текстовых сведений об уловах, хранение исходной ссылки и нормализованных полей в закрытом staging.
|
- Разрешённый технический контур: получение публичного HTML, извлечение текстовых сведений об уловах, хранение исходной ссылки и нормализованных полей в закрытом staging.
|
||||||
- Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия.
|
- Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия.
|
||||||
- Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance.
|
- Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance.
|
||||||
|
- Публичное отображение: каждая запись и агрегат обязаны показывать источник; неполные staging-наблюдения разрешено отображать сразу с прямой ссылкой, явной пометкой неполноты и перечнем отсутствующих полей, но без влияния на индекс активности.
|
||||||
- Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора.
|
- Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора.
|
||||||
- Публикация: автоматически по ранее вручную подтверждённым алиасам источника либо вручную; обязательны рыба, водоём, координаты и вес.
|
- Публикация: автоматически по ранее вручную подтверждённым алиасам источника либо вручную; обязательны рыба, водоём, координаты и вес.
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
- Частота: не чаще одного получения страницы каждого источника раз в 30 минут.
|
- Частота: не чаще одного получения страницы каждого источника раз в 30 минут.
|
||||||
- Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются.
|
- Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются.
|
||||||
- Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются.
|
- Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются.
|
||||||
|
- Атрибуция и неполнота: на каждой публичной карточке показываются источник и ссылка; неполные наблюдения могут появляться сразу, если визуально отделены от подтверждённых данных и не участвуют в расчёте активности.
|
||||||
- Публикация: автоматически по подтверждённым алиасам либо вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging.
|
- Публикация: автоматически по подтверждённым алиасам либо вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging.
|
||||||
- Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями.
|
- Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,8 @@ RF4 Spotter обрабатывает официальные публичные
|
|||||||
- IP не хранится: rate limit использует HMAC-отпечаток с секретом сервера;
|
- IP не хранится: rate limit использует HMAC-отпечаток с секретом сервера;
|
||||||
- из изображений удаляются EXIF и прочие метаданные;
|
- из изображений удаляются EXIF и прочие метаданные;
|
||||||
- query string, authorization headers и пользовательские payload не попадают в HTTP-лог;
|
- query string, authorization headers и пользовательские payload не попадают в HTTP-лог;
|
||||||
- community-наблюдения сначала поступают в изолированный staging; полные записи автоматически публикуются только по ранее вручную подтверждённым алиасам источника, остальные требуют ручной проверки.
|
- community-наблюдения сначала поступают в staging; неполные можно сразу видеть в отдельной публичной ленте с источником и перечнем отсутствующих полей, но они не влияют на индекс активности. Полные записи автоматически входят в основной индекс только по ранее вручную подтверждённым алиасам источника, остальные требуют ручной проверки.
|
||||||
|
- любая карточка улова, рекорда или агрегированной активности показывает первоисточник либо полный список вошедших в агрегат источников.
|
||||||
|
|
||||||
## Источники и атрибуция
|
## Источники и атрибуция
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user