Compare commits

..
4 Commits
Author SHA1 Message Date
ik 870d9cc7f9 feat: harden production data and backups
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-06 14:08:07 +07:00
ik a4bd395856 feat: add production deployment for rf4spotter.ru 2026-09-06 13:52:30 +07:00
ik b45ba38a00 feat: add fishing iconography and float gauge 2026-09-06 13:37:03 +07:00
ik 366ff83a60 fix: unify activity labels and Russian plurals 2026-09-06 13:31:44 +07:00
33 changed files with 668 additions and 63 deletions
+27
View File
@@ -0,0 +1,27 @@
# DNS: A/AAAA records for both names must point to the deployment server.
SITE_DOMAIN=rf4spotter.ru
FILES_DOMAIN=files.rf4spotter.ru
ACME_EMAIL=admin@rf4spotter.ru
POSTGRES_DB=rf4_spotter
POSTGRES_USER=rf4
POSTGRES_PASSWORD=replace-with-long-random-value
# URL-encode special characters from POSTGRES_PASSWORD in this URL.
DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter
ADMIN_TOKEN=replace-with-at-least-32-random-characters
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
ADMIN_BASIC_USER=rf4admin
# Generate with: docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'YOUR PASSWORD'
ADMIN_BASIC_PASSWORD_HASH=replace-with-caddy-password-hash
S3_ACCESS_KEY=replace-with-random-access-key
S3_SECRET_KEY=replace-with-at-least-32-random-characters
S3_BUCKET=catch-screenshots
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
OFFICIAL_RECORDS_REGION=RU
OFFICIAL_RECORDS_CATEGORY=records
OFFICIAL_IMPORT_REQUIRED=false
IMPORT_INTERVAL_SECONDS=3600
LOG_LEVEL=INFO
+1
View File
@@ -2,6 +2,7 @@ __pycache__/
*.py[cod]
.pytest_cache/
.cache/
.env.production
.venv/
node_modules/
dist/
+2
View File
@@ -14,6 +14,8 @@ RF4 Spotter — неофициальный сервис свежих точек
Подробный план и актуальные чекбоксы находятся в [`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 добавляет только справочники — демонстрационные уловы отключены.
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
@@ -0,0 +1,16 @@
"""Protect pending screenshot uploads with a one-time token."""
from alembic import op
import sqlalchemy as sa
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("catch_report", sa.Column("screenshot_upload_token_hash", sa.String(64)))
def downgrade() -> None:
op.drop_column("catch_report", "screenshot_upload_token_hash")
+25 -1
View File
@@ -1,8 +1,9 @@
from pydantic import Field
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
deployment_environment: str = "development"
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
admin_token: str = "change-me-in-production"
s3_endpoint_url: str = "http://localhost:9000"
@@ -15,10 +16,33 @@ class Settings(BaseSettings):
official_records_region: str = "RU"
official_records_category: str = "records"
official_import_required: bool = False
seed_demo_data: bool = True
import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret"
log_level: str = "INFO"
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@model_validator(mode="after")
def reject_insecure_production_defaults(self) -> "Settings":
if self.deployment_environment != "production":
return self
insecure = {
"ADMIN_TOKEN": self.admin_token == "change-me-in-production" or len(self.admin_token) < 32,
"RATE_LIMIT_SECRET": self.rate_limit_secret == "change-rate-limit-secret" or len(self.rate_limit_secret) < 32,
"S3_ACCESS_KEY": self.s3_access_key == "rf4-local" or len(self.s3_access_key) < 12,
"S3_SECRET_KEY": self.s3_secret_key == "rf4-local-secret" or len(self.s3_secret_key) < 32,
}
invalid = [name for name, failed in insecure.items() if failed]
if invalid:
raise ValueError(f"insecure production settings: {', '.join(invalid)}")
if not self.cors_origins or any(not origin.startswith("https://") for origin in self.cors_origins):
raise ValueError("production CORS_ORIGINS must contain only HTTPS origins")
if not self.s3_public_endpoint_url.startswith("https://"):
raise ValueError("production S3_PUBLIC_ENDPOINT_URL must use HTTPS")
if self.seed_demo_data:
raise ValueError("SEED_DEMO_DATA must be false in production")
return self
settings = Settings()
+21 -7
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import logging
import secrets
import time as time_module
from typing import Annotated, Literal
from uuid import UUID
@@ -25,7 +26,7 @@ from .importer import 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 .readiness import readiness_report
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, 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, SpotOut, WaterbodyOut
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
@@ -34,7 +35,7 @@ logger = logging.getLogger("rf4.api")
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"],
allow_origins=settings.cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
@@ -50,6 +51,11 @@ async def structured_request_log(request: Request, call_next):
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
if settings.deployment_environment == "production":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
except Exception as exc:
logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__})
@@ -268,8 +274,8 @@ def admin_reject_external_observation(
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
@app.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted:
if payload.website:
raise HTTPException(status_code=400, detail="invalid submission")
_check_rate_limit(request.client.host if request.client else "unknown", db)
@@ -288,17 +294,24 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) ->
if bait is None:
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
db.add(bait)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None)
upload_token = secrets.token_urlsafe(32)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
db.add(report)
db.commit()
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token)
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) -> Response:
def add_screenshot(
report_id: UUID, db: Db, screenshot: UploadFile = File(),
upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None,
) -> Response:
report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
raise HTTPException(status_code=404, detail="pending catch report not found")
supplied_hash = hashlib.sha256((upload_token or "").encode()).hexdigest()
if not report.screenshot_upload_token_hash or not hmac.compare_digest(report.screenshot_upload_token_hash, supplied_hash):
raise HTTPException(status_code=401, detail="invalid screenshot upload token")
if report.screenshot_key:
raise HTTPException(status_code=409, detail="screenshot already uploaded")
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
@@ -306,6 +319,7 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) ->
report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type)
except ScreenshotError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
report.screenshot_upload_token_hash = None
db.commit()
return Response(status_code=204)
+1
View File
@@ -92,6 +92,7 @@ class CatchReport(Base):
source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
screenshot_key: Mapped[str | None] = mapped_column(Text)
screenshot_upload_token_hash: Mapped[str | None] = mapped_column(String(64))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship()
+4
View File
@@ -137,6 +137,10 @@ class CatchReportCreated(BaseModel):
moderation_status: str
class CatchReportAccepted(CatchReportCreated):
screenshot_upload_token: str
class AdminCatchReportOut(BaseModel):
id: UUID
fish: str
+35 -10
View File
@@ -5,6 +5,7 @@ from uuid import UUID
from sqlalchemy import select
from .config import settings
from .database import SessionLocal
from .models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
@@ -23,19 +24,38 @@ IDS = {
def seed() -> None:
with SessionLocal.begin() as db:
if db.scalar(select(Fish.id).limit(1)) is not None:
def entity(model, key: str, value: str, **values):
item = db.scalar(select(model).where(getattr(model, key) == value))
if item is None:
item = model(**values)
db.add(item)
db.flush()
return item
vyunok = entity(Waterbody, "slug", "vyunok", id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
kuori = entity(Waterbody, "slug", "kuori", id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
pike = entity(Fish, "slug", "pike", id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
trout = entity(Fish, "slug", "lake-trout", id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
spiker = entity(Bait, "normalized_name", "spiker #2 01-015", id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
shad = entity(Bait, "normalized_name", "salmon t1 shad 12 005", id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
def spot(waterbody: Waterbody, identity: str, x: int, y: int, description: str) -> Spot:
item = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == x, Spot.y == y))
if item is None:
item = Spot(id=IDS[identity], waterbody=waterbody, x=x, y=y, description=description)
db.add(item)
db.flush()
return item
spot1 = spot(vyunok, "spot1", 110, 103, "Кромка травы у северного берега")
spot2 = spot(kuori, "spot2", 85, 92, "Свальчик в глубину")
if not settings.seed_demo_data:
return
vyunok = Waterbody(id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
kuori = Waterbody(id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
pike = Fish(id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
trout = Fish(id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
spiker = Bait(id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
shad = Bait(id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
spot1 = Spot(id=IDS["spot1"], waterbody=vyunok, x=110, y=103, description="Кромка травы у северного берега")
spot2 = Spot(id=IDS["spot2"], waterbody=kuori, x=85, y=92, description="Свальчик в глубину")
db.add_all([vyunok, kuori, pike, trout, spiker, shad, spot1, spot2])
now = datetime.now(timezone.utc)
for index in range(12):
external_id = f"seed:pike:{index}"
if db.scalar(select(CatchReport.id).where(CatchReport.source_external_id == external_id)):
continue
db.add(CatchReport(
fish=pike, spot=spot1, waterbody=vyunok, bait=spiker,
weight_g=2_600 + index * 480, fishing_method="spinning",
@@ -43,9 +63,13 @@ def seed() -> None:
caught_at=now - timedelta(minutes=25 + index * 47),
reported_at=now - timedelta(minutes=20 + index * 47),
player_name=f"DemoPlayer{index % 7 + 1}", source_type=SourceType.manual_import,
source_external_id=external_id,
source_confidence=80 + index % 3 * 5, moderation_status=ModerationStatus.approved,
))
for index in range(5):
external_id = f"seed:trout:{index}"
if db.scalar(select(CatchReport.id).where(CatchReport.source_external_id == external_id)):
continue
db.add(CatchReport(
fish=trout, spot=spot2, waterbody=kuori, bait=shad,
weight_g=4_200 + index * 900, fishing_method="spinning",
@@ -53,6 +77,7 @@ def seed() -> None:
caught_at=now - timedelta(hours=2 + index * 4),
reported_at=now - timedelta(hours=2 + index * 4),
player_name=f"DemoAngler{index + 1}", source_type=SourceType.manual_import,
source_external_id=external_id,
source_confidence=85, moderation_status=ModerationStatus.approved,
))
+6 -3
View File
@@ -187,10 +187,13 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
response = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
upload_url = f"/api/v1/catch-reports/{created['id']}/screenshot"
assert client.post(upload_url, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
assert client.post(upload_url, headers={"X-Upload-Token": "wrong"}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
response = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert response.status_code == 204
duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert duplicate.status_code == 409
reused = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert reused.status_code == 401
def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None:
+38
View File
@@ -0,0 +1,38 @@
import pytest
from pydantic import ValidationError
from app.config import Settings
def production_settings(**changes) -> Settings:
values = {
"deployment_environment": "production",
"admin_token": "a" * 32,
"rate_limit_secret": "r" * 32,
"s3_access_key": "access-key-1234",
"s3_secret_key": "s" * 32,
"s3_public_endpoint_url": "https://files.rf4spotter.ru",
"cors_origins": ["https://rf4spotter.ru"],
"seed_demo_data": False,
}
return Settings(**(values | changes))
def test_production_settings_accept_real_domains_and_secrets() -> None:
settings = production_settings()
assert settings.cors_origins == ["https://rf4spotter.ru"]
@pytest.mark.parametrize(("field", "value"), [
("admin_token", "change-me-in-production"),
("rate_limit_secret", "short"),
("s3_access_key", "rf4-local"),
("s3_secret_key", "rf4-local-secret"),
("cors_origins", ["http://rf4spotter.ru"]),
("s3_public_endpoint_url", "http://files.rf4spotter.ru"),
("seed_demo_data", True),
])
def test_production_settings_reject_insecure_values(field: str, value: object) -> None:
with pytest.raises(ValidationError):
production_settings(**{field: value})
+27
View File
@@ -0,0 +1,27 @@
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import CatchReport, Fish, Spot, Waterbody
from app import seed as seed_module
def test_seed_repairs_partial_database_and_is_idempotent(monkeypatch) -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine, expire_on_commit=False)
with Session(engine) as db:
db.add(Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000))
db.commit()
monkeypatch.setattr(seed_module, "SessionLocal", sessions)
monkeypatch.setattr(seed_module.settings, "seed_demo_data", False)
seed_module.seed()
seed_module.seed()
with Session(engine) as db:
assert db.scalar(select(func.count()).select_from(Fish)) == 2
assert db.scalar(select(func.count()).select_from(Waterbody)) == 2
assert db.scalar(select(func.count()).select_from(Spot)) == 2
assert db.scalar(select(func.count()).select_from(CatchReport)) == 0
+1
View File
@@ -8,6 +8,7 @@
"build": "astro check && astro build",
"start": "node ./dist/server/entry.mjs",
"check": "astro check",
"test:unit": "node --test tests/unit/*.test.ts",
"test:e2e": "playwright test"
},
"dependencies": {
+1
View File
@@ -2,6 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
testIgnore: "**/unit/**",
use: { baseURL: process.env.WEB_URL || "http://127.0.0.1:4321" },
retries: 0,
});
+5 -4
View File
@@ -1,12 +1,13 @@
---
import type { Activity } from "../lib/api";
import { ago, kg } from "../lib/api";
import { activityLevel, ago, kg, plural } from "../lib/api";
import FishingIcon from "./FishingIcon.astro";
const { item } = Astro.props as { item: Activity };
const level = item.activity_score >= 80 ? "Очень высокий" : item.activity_score >= 60 ? "Высокий" : item.activity_score >= 40 ? "Средний" : "Низкий";
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"><i></i>{level}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span>⌖ {item.x}:{item.y}</span><span>◷ {ago(item.last_confirmed_at)}</span></div><p class="data-note">{item.explanation}</p><div class="bait-line"><span class="lure-dot"></span><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>уловов</span></div><div><strong>{item.unique_players}</strong><span>игроков</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"></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-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>
+17
View File
@@ -0,0 +1,17 @@
---
type IconName = "hook" | "float" | "trophy" | "plus" | "pin" | "clock" | "ripple" | "angler" | "scale" | "lure" | "arrow";
const { name, size = 18, label } = Astro.props as { name: IconName; size?: number; label?: string };
---
<svg class={`fish-icon fish-icon--${name}`} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden={label ? undefined : "true"} role={label ? "img" : undefined} aria-label={label}>
{name === "hook" && <><path d="M15 3v11a6 6 0 1 1-6-6"/><path d="m6 9 3-1 1 3"/><path d="M15 3h3"/></>}
{name === "float" && <><path d="M12 2v3M12 19v3"/><path d="M12 5c2.2 0 3 2.8 3 6s-.8 8-3 8-3-4.8-3-8 .8-6 3-6Z"/><path d="M9.2 11h5.6"/></>}
{name === "trophy" && <><path d="M8 4h8v4a4 4 0 0 1-8 0V4Z"/><path d="M8 6H5c0 3 1.5 5 4.5 5M16 6h3c0 3-1.5 5-4.5 5M12 12v5M8 20h8M9 17h6"/></>}
{name === "plus" && <><path d="M12 5v14M5 12h14"/><circle cx="12" cy="12" r="9"/></>}
{name === "pin" && <><path d="M12 21s6-6.1 6-12a6 6 0 1 0-12 0c0 5.9 6 12 6 12Z"/><circle cx="12" cy="9" r="2"/></>}
{name === "clock" && <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>}
{name === "ripple" && <><circle cx="12" cy="12" r="2"/><path d="M6.6 8.4A6.5 6.5 0 0 0 5.5 12c0 1.3.4 2.5 1.1 3.6M17.4 8.4a6.5 6.5 0 0 1 1.1 3.6c0 1.3-.4 2.5-1.1 3.6M3.5 5.7A10 10 0 0 0 2 12c0 2.4.8 4.6 2.1 6.3M20.5 5.7A10 10 0 0 1 22 12c0 2.4-.8 4.6-2.1 6.3"/></>}
{name === "angler" && <><circle cx="9" cy="6" r="2"/><path d="m8 9-2 5 4 2 2 5M8 12l5 2M13 14l5-8M17 6c2 3 3 6 3 10"/></>}
{name === "scale" && <><path d="M12 3v18M7 6h10M6 6l-3 6h6L6 6ZM18 6l-3 6h6l-3-6ZM8 21h8"/></>}
{name === "lure" && <><path d="M9 4c4 1 7 5 6 10s-5 7-8 4 1-7 2-14Z"/><path d="m14 8 4-2M8 18c0 3 4 3 4 0"/></>}
{name === "arrow" && <><path d="M5 12h14M14 7l5 5-5 5"/></>}
</svg>
+4 -3
View File
@@ -1,5 +1,6 @@
---
import "../styles/global.css";
import FishingIcon from "../components/FishingIcon.astro";
const { title = "RF4 Spotter" } = Astro.props;
const path = Astro.url.pathname;
---
@@ -8,11 +9,11 @@ const path = Astro.url.pathname;
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><link rel="icon" href="/favicon.svg" /><title>{title}</title></head>
<body>
<header class="topbar">
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true"></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} href="/"> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} href="/records"> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} href="/report"> <span>Добавить улов</span></a></nav>
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
</header>
<main><slot /></main>
<footer><a href="/" class="brand"><span class="brand-mark"></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. Данные seed демонстрационные.</p><span>НХНЧ · 2026</span></footer>
<footer><a href="/" class="brand"><span class="brand-mark"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. Данные seed демонстрационные.</p><span>НХНЧ · 2026</span></footer>
</body>
</html>
+2 -6
View File
@@ -12,6 +12,8 @@ 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 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";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export async function api<T>(path: string): Promise<T> {
@@ -19,9 +21,3 @@ export async function api<T>(path: string): Promise<T> {
if (!response.ok) throw new Error(`API ${response.status}`);
return response.json() as Promise<T>;
}
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
export function ago(value: string) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
}
+22
View File
@@ -0,0 +1,22 @@
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
export function plural(number: number, forms: readonly [string, string, string]) {
const absolute = Math.abs(number) % 100;
const last = absolute % 10;
if (absolute > 10 && absolute < 20) return forms[2];
if (last === 1) return forms[0];
if (last >= 2 && last <= 4) return forms[1];
return forms[2];
}
export function activityLevel(score: number) {
if (score >= 80) return { short: "Очень высокий", description: "Очень высокая активность" };
if (score >= 60) return { short: "Высокий", description: "Высокая активность" };
if (score >= 40) return { short: "Средний", description: "Средняя активность" };
return { short: "Низкий", description: "Низкая активность" };
}
export function ago(value: string) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
}
+6 -3
View File
@@ -2,14 +2,17 @@ import type { APIRoute } from "astro";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export const POST: APIRoute = async ({ request, redirect }) => {
export const POST: APIRoute = async ({ request, redirect, cookies }) => {
const form = await request.formData();
const reportId = String(form.get("report_id") || "");
const screenshot = form.get("screenshot");
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId) || !(screenshot instanceof File) || screenshot.size === 0) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
const cookieName = `rf4-upload-${reportId}`;
const uploadToken = cookies.get(cookieName)?.value;
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId) || !uploadToken || !(screenshot instanceof File) || screenshot.size === 0) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
try {
const upload = new FormData(); upload.set("screenshot", screenshot);
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", body: upload });
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload });
if (response.ok) cookies.delete(cookieName, {path:"/"});
return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
} catch { return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); }
};
+14 -5
View File
@@ -1,23 +1,32 @@
import type { APIRoute } from "astro";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export const POST: APIRoute = async ({ request, redirect }) => {
export const POST: APIRoute = async ({ request, redirect, cookies }) => {
const form = await request.formData();
const text = (name: string) => String(form.get(name) || "").trim() || null;
const number = (name: string) => text(name) ? Number(text(name)) : null;
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
let createdId: string | null = null;
let uploadToken: string | null = null;
try {
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
if (!response.ok) return redirect("/report?state=create_error", 303);
const created = await response.json();
const created = await response.json() as { id?: unknown; screenshot_upload_token?: unknown };
if (typeof created.id !== "string" || typeof created.screenshot_upload_token !== "string") return redirect("/report?state=create_error", 303);
createdId = created.id;
uploadToken = created.screenshot_upload_token;
const screenshot = form.get("screenshot");
if (screenshot instanceof File && screenshot.size > 0) {
const upload = new FormData(); upload.set("screenshot", screenshot);
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", body: upload });
if (!uploaded.ok) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload });
if (!uploaded.ok) {
cookies.set(`rf4-upload-${created.id}`, created.screenshot_upload_token, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
}
}
return redirect("/report?state=sent", 303);
}
catch { return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=create_error", 303); }
catch {
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=create_error", 303);
}
};
+6 -4
View File
@@ -1,7 +1,8 @@
---
import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro";
import { ago, api, kg, type Activity, type DictionaryItem } from "../lib/api";
import FishingIcon from "../components/FishingIcon.astro";
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem } from "../lib/api";
const params = Astro.url.searchParams;
const hours = params.get("hours") ?? "24";
@@ -18,9 +19,10 @@ try {
items = await api<Activity[]>(`/api/v1/activity?${query}`);
}
} catch { unavailable = true; }
const leaderLevel = items[0] ? activityLevel(items[0].activity_score) : null;
---
<Layout title="Что клюёт сейчас — RF4 Spotter">
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} точки показывают активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
<section class="filters-wrap"><form class="filters content-grid" method="get">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
@@ -28,8 +30,8 @@ try {
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
<button>⌕ Найти клёв</button>
</form></section>
<section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{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] && <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="Открыть точку"></a></div><div class="detail-score"><div class="score-ring" style={`--score:${items[0].activity_score * 3.6}deg`}><div><strong>{items[0].activity_score}</strong><span>из 100</span></div></div><div><span>Индекс активности</span><strong>{items[0].activity_score >= 70 ? "Клёв отличный" : "Клёв хороший"}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span>♟</span><small>Игроков</small><strong>{items[0].unique_players}</strong></div><div><span></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><i></i><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} уловов</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
<section class="dashboard content-grid"><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>}
</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>
</Layout>
+3 -2
View File
@@ -1,18 +1,19 @@
---
import Layout from "../../layouts/Layout.astro";
import { api, kg, 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;
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
try {
const [spotResult, catchResult, activityRows] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
} catch { unavailable = true; }
const level = activity ? activityLevel(activity.activity_score) : null;
---
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
<a class="back" href="/">← Все активные точки</a>
{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 ? <><h2>{activity.activity_score} / 100</h2><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} наблюдениях от {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></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>
</>}
</Layout>
+6
View File
@@ -15,3 +15,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
@media(max-width:480px){.filters{grid-template-columns:1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
/* RF4 field-guide iconography and float activity gauge */
.fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)}
.float-gauge{width:122px;height:142px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:8px;isolation:isolate}.float-gauge:before,.float-gauge:after{content:"";position:absolute;left:3px;right:3px;border:1px solid #ffffff1d;border-radius:50%}.float-gauge:before{height:28px;bottom:25px;box-shadow:0 0 0 10px #ffffff08}.float-gauge:after{height:15px;bottom:32px}.float-gauge__line{position:absolute;left:50%;top:0;width:1px;height:105px;background:linear-gradient(#ffffff55,#ffffff12);z-index:-1}.float-gauge__water{position:absolute;left:11px;right:11px;bottom:39px;height:calc(var(--level)*.62);max-height:62px;background:linear-gradient(180deg,#c9f45b08,#c9f45b36);border-top:1px solid var(--lime);clip-path:polygon(0 6px,18% 0,36% 6px,54% 0,72% 6px,90% 0,100% 4px,100% 100%,0 100%)}.float-gauge__bob{position:absolute;left:50%;bottom:calc(39px + var(--level)*.62);width:18px;height:54px;transform:translate(-50%,50%);border:1px solid #e6f8ad;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,var(--lime) 0 35%,#f4f7ed 36% 58%,#e47e3b 59%);box-shadow:0 5px 18px #c9f45b38}.float-gauge__bob i{position:absolute;left:50%;top:-24px;width:1px;height:25px;background:#dfe9dc}.float-gauge strong{position:relative;z-index:2;font:400 31px/1 Georgia,serif;text-shadow:0 2px 8px var(--deep)}.float-gauge small{position:relative;z-index:2;color:#a9b8b5;font-size:9px;text-transform:uppercase}.best-lure>div{grid-template-columns:25px 1fr auto}
@media(max-width:720px){.float-gauge{width:100px}}
@media(prefers-reduced-motion:no-preference){.pulse-orb span{animation:ripple-pulse 2.4s ease-out infinite}.float-gauge__bob{animation:bob 2.8s ease-in-out infinite}}@keyframes ripple-pulse{50%{box-shadow:0 0 0 15px #c9f45b08}}@keyframes bob{50%{translate:0 -3px}}
+10 -6
View File
@@ -3,11 +3,15 @@ import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
const seedSpot = page.getByTestId("spot-110-103");
await expect(seedSpot.getByRole("heading", { name: "Щука" })).toBeVisible();
await seedSpot.click();
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
const activeSpots = page.locator(".spot-card[data-testid]");
await expect(activeSpots).not.toHaveCount(0);
const activeSpot = activeSpots.first();
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("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
});
test("submitted catch appears publicly only after moderation", async ({ page }) => {
@@ -49,8 +53,8 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
await page.getByLabel("Сначала").selectOption("freshness");
await page.getByRole("button", { name: "⌕ Найти клёв" }).click();
await expect(page).toHaveURL(/waterbody=kuori.*fish=lake-trout.*hours=72.*sort=freshness/);
await expect(page.getByTestId("spot-85-92")).toBeVisible();
await expect(page.getByTestId("spot-110-103")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Горячие точки" })).toBeVisible();
await expect(page.locator(".spot-list, .state")).toBeVisible();
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
});
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { activityLevel, plural } from "../../src/lib/presentation.ts";
test("activity levels share one complete 0-100 scale", () => {
assert.deepEqual(
[0, 39, 40, 59, 60, 79, 80, 100].map(score => activityLevel(score).short),
["Низкий", "Низкий", "Средний", "Средний", "Высокий", "Высокий", "Очень высокий", "Очень высокий"],
);
});
test("Russian plural forms cover common and exceptional endings", () => {
const forms = ["точка", "точки", "точек"] as const;
assert.deepEqual(
[0, 1, 2, 5, 11, 21, 22, 25].map(number => plural(number, forms)),
["точек", "точка", "точки", "точек", "точек", "точка", "точки", "точек"],
);
});
+143
View File
@@ -0,0 +1,143 @@
name: rf4-spotter
services:
proxy:
image: caddy:2.10.2-alpine
restart: unless-stopped
environment:
SITE_DOMAIN: ${SITE_DOMAIN:?Set SITE_DOMAIN}
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL}
ADMIN_BASIC_USER: ${ADMIN_BASIC_USER:?Set ADMIN_BASIC_USER}
ADMIN_BASIC_PASSWORD_HASH: ${ADMIN_BASIC_PASSWORD_HASH:?Set ADMIN_BASIC_PASSWORD_HASH}
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
web:
condition: service_healthy
networks: [edge, backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "0.50", memory: 256M}
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-rf4_spotter}
POSTGRES_USER: ${POSTGRES_USER:-rf4}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rf4} -d ${POSTGRES_DB:-rf4_spotter}"]
interval: 10s
timeout: 5s
retries: 10
networks: [backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "1.00", memory: 1G}
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
command: server /data
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 10
networks: [backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "0.75", memory: 1G}
api:
build:
context: .
dockerfile: apps/api/Dockerfile
restart: unless-stopped
environment:
DEPLOYMENT_ENVIRONMENT: production
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN}
CORS_ORIGINS: '["https://${SITE_DOMAIN:?Set SITE_DOMAIN}"]'
S3_ENDPOINT_URL: http://minio:9000
S3_PUBLIC_ENDPOINT_URL: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/}
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false}
SEED_DEMO_DATA: "false"
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
db: {condition: service_healthy}
minio: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
interval: 10s
timeout: 5s
retries: 12
networks: [backend, edge]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "1.00", memory: 1G}
web:
build: ./apps/web
restart: unless-stopped
environment:
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
API_INTERNAL_URL: http://api:8000
depends_on:
api: {condition: service_healthy}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:4321').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 10
networks: [backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "0.75", memory: 512M}
storage-tool:
image: alpine:3.22
profiles: [tools]
volumes:
- minio_data:/data
- ${BACKUP_DIRECTORY:-./backups}:/backup
security_opt: [no-new-privileges:true]
networks:
edge:
backend:
internal: true
volumes:
postgres_data:
minio_data:
caddy_data:
caddy_config:
+44
View File
@@ -0,0 +1,44 @@
{
email {$ACME_EMAIL}
admin off
}
{$SITE_DOMAIN} {
encode zstd gzip
@admin path /admin/*
handle @admin {
basic_auth {
{$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH}
}
reverse_proxy web:4321
}
@api path /api/* /health /ready
handle @api {
reverse_proxy api:8000
}
handle {
reverse_proxy web:4321
}
header {
-Server
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
}
}
{$FILES_DOMAIN} {
encode zstd gzip
reverse_proxy minio:9000
header {
-Server
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
}
}
+92
View File
@@ -0,0 +1,92 @@
# Развёртывание закрытой альфы rf4spotter.ru
Production-контур рассчитан на один Linux-сервер с Docker Compose. Наружу публикуются только Caddy `80/443`; PostgreSQL, FastAPI и MinIO не имеют host-портов. Административные страницы защищены одновременно Caddy Basic Auth и API bearer token.
## 1. DNS и сервер
Создайте A-записи `rf4spotter.ru` и `files.rf4spotter.ru` на публичный IPv4 сервера. При наличии рабочего IPv6 добавьте AAAA для обоих имён. До запуска убедитесь, что извне доступны TCP 80/443 и UDP 443; SSH ограничьте своим IP или VPN. Порты 4321, 8000, 9000, 9001 и 5432 открывать нельзя.
Минимум для закрытой альфы: 2 vCPU, 4 ГБ RAM и 40 ГБ SSD. Рекомендуется 4 vCPU, 8 ГБ RAM и отдельное внешнее место для резервных копий.
## 2. Секреты
На сервере:
```bash
cp .env.production.example .env.production
chmod 600 .env.production
openssl rand -base64 36 # повторить для пароля БД, ADMIN_TOKEN, RATE_LIMIT_SECRET и S3_SECRET_KEY
docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ'
```
Заполните `.env.production`. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
## 3. Проверка и первый запуск
```bash
docker compose --env-file .env.production -f compose.production.yaml config --quiet
docker compose --env-file .env.production -f compose.production.yaml build
docker compose --env-file .env.production -f compose.production.yaml up -d
docker compose --env-file .env.production -f compose.production.yaml ps
curl -fsS https://rf4spotter.ru/health
curl -fsS https://rf4spotter.ru/ready
```
API-контейнер перед стартом применяет Alembic-миграции и запускает идемпотентный seed. В production он добавляет только минимальные справочники и точки; демонстрационные уловы жёстко отключены настройкой `SEED_DEMO_DATA=false`.
Проверка TLS и маршрутизации:
```bash
curl -I https://rf4spotter.ru/
curl -I https://files.rf4spotter.ru/minio/health/live
```
MinIO health URL допустим для диагностики, но Console наружу не публикуется. Объекты доступны только по временным подписанным ссылкам.
## 4. Первичные данные
Официальный импорт запускается вручную после успешного readiness:
```bash
docker compose --env-file .env.production -f compose.production.yaml exec api python -m app.cli import-records
```
Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging.
## 5. Обновление
```bash
git pull --ff-only
docker compose --env-file .env.production -f compose.production.yaml build
docker compose --env-file .env.production -f compose.production.yaml up -d
docker compose --env-file .env.production -f compose.production.yaml ps
curl -fsS https://rf4spotter.ru/ready
```
Перед обновлением со сменой схемы обязателен backup PostgreSQL. Не удаляйте volumes и не используйте `down -v`.
## 6. Резервное копирование и восстановление
Храните копии вне диска приложения. Скрипт создаёт PostgreSQL dump, архив MinIO и контрольные суммы в новом каталоге с UTC-временем:
```bash
./deploy/backup.sh /srv/rf4-backups
```
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API, web и MinIO. Это намеренно защищённая подтверждением операция:
```bash
CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z
curl -fsS https://rf4spotter.ru/ready
```
Перед приглашением пользователей проведите учебное восстановление на отдельном сервере или с отдельным Compose project name. Одной только успешной архивации недостаточно. После проверки настройте ежедневный запуск `backup.sh`, шифрование/выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии.
## 7. Что ещё блокирует приглашение альфа-пользователей
- учебное восстановление PostgreSQL и MinIO на изолированном контуре;
- проверка полного bootstrap на пустых volumes;
- политика хранения и удаления пользовательских данных;
- базовый мониторинг `/ready`, диска и срока TLS-сертификата.
До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям.
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
echo "Usage: $0 ABSOLUTE_BACKUP_DIRECTORY" >&2
exit 2
fi
case "$1" in
/*) ;;
*) echo "Backup directory must be an absolute path" >&2; exit 2 ;;
esac
backup_root=${1%/}
stamp=$(date -u +%Y%m%dT%H%M%SZ)
target="$backup_root/$stamp"
mkdir -p "$target"
chmod 700 "$target"
export BACKUP_DIRECTORY="$backup_root"
compose="docker compose --env-file .env.production -f compose.production.yaml"
$compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/postgres.dump"
$compose run --rm storage-tool tar -czf "/backup/$stamp/minio.tar.gz" -C /data .
sha256sum "$target/postgres.dump" "$target/minio.tar.gz" > "$target/SHA256SUMS"
chmod 600 "$target/postgres.dump" "$target/minio.tar.gz" "$target/SHA256SUMS"
echo "Backup created: $target"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ] || [ "${CONFIRM_RESTORE:-}" != "rf4-spotter" ]; then
echo "Usage: CONFIRM_RESTORE=rf4-spotter $0 ABSOLUTE_BACKUP_DIRECTORY/TIMESTAMP" >&2
exit 2
fi
case "$1" in
/*) ;;
*) echo "Backup directory must be an absolute path" >&2; exit 2 ;;
esac
source_dir=${1%/}
test -f "$source_dir/postgres.dump"
test -f "$source_dir/minio.tar.gz"
(cd "$source_dir" && sha256sum -c SHA256SUMS)
backup_root=$(dirname "$source_dir")
stamp=$(basename "$source_dir")
export BACKUP_DIRECTORY="$backup_root"
compose="docker compose --env-file .env.production -f compose.production.yaml"
$compose stop api web minio
$compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump"
$compose run --rm storage-tool sh -c 'find /data -mindepth 1 -delete && tar -xzf "/backup/'"$stamp"'/minio.tar.gz" -C /data'
$compose up -d minio api web proxy
echo "Restore completed from: $source_dir"
+6 -5
View File
@@ -60,17 +60,18 @@
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
- [ ] Добавить отдельный тест полного bootstrap: пустые volumes → миграции → seed → readiness → основной E2E.
- [ ] Сделать seed устойчивым к частично заполненной БД и покрыть повторный/частичный запуск тестом; текущая реализация прекращает работу при наличии любой рыбы.
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
- [ ] Проверить авторизацию повторной загрузки скриншота: один UUID pending-заявки не должен быть достаточным полномочием для изменения чужой записи.
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
- [ ] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавить документированную очистку/анонимизацию.
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO; проверить восстановление на отдельных временных volumes.
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: скрипты, контрольные суммы и runbook готовы; остаётся учебное восстановление на отдельных временных volumes.
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
- [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`).
- [ ] Исправить единую шкалу активности и русские числительные во всех публичных представлениях (пакет A UI/UX-аудита).
- [x] Исправить единую шкалу активности и русские числительные во всех публичных представлениях (пакет A UI/UX-аудита; unit-тест границ и E2E согласованности).
- [x] Дополнить визуальный язык авторскими рыболовными SVG-иконками и шкалой активности в форме поплавка; анимации учитывают `prefers-reduced-motion`.
- [ ] Сократить мобильный путь до результатов и улучшить фильтры (пакет B UI/UX-аудита).
- [ ] Упростить форму улова, добавить помощь форматов и сохранение значений при ошибке (пакет C UI/UX-аудита).
- [ ] Заменить slug-фильтры и улучшить мобильное представление рекордов/detail (пакет D UI/UX-аудита).
@@ -78,7 +79,7 @@
- [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
- [ ] Выбрать лицензию кода и политику использования данных.
- [ ] Определить production-профиль Compose/развёртывания: TLS/reverse proxy, домены, CORS, volumes, restart policy, resource limits и порядок обновления миграций.
- [ ] Завершить production-профиль для `rf4spotter.ru`: Compose, Caddy/TLS, закрытые внутренние сервисы, CORS, resource limits, fail-fast секреты и runbook добавлены; остаются backup/restore и проверка на целевом сервере.
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
## Источники данных и согласование
+11 -4
View File
@@ -34,12 +34,19 @@
## План работ
### Визуальный язык — выполнено
- [x] Добавлен единый набор тонких рыболовных SVG-иконок без внешней библиотеки.
- [x] Unicode-символы навигации, метрик, координат, времени и приманки заменены тематическими пиктограммами.
- [x] Кольцевая диаграмма лидера заменена авторской шкалой-поплавком: высота поплавка и воды связана с индексом активности.
- [x] Добавлена деликатная рябь и покачивание с учётом `prefers-reduced-motion`.
### Пакет A — достоверность интерфейса
- [ ] Вынести шкалу активности и пользовательские формулировки в общий helper.
- [ ] Устранить противоречие карточки, sidebar и detail для всех диапазонов 0–100.
- [ ] Добавить helper русских числительных и unit-тесты граничных значений.
- [ ] Проверить тексты состояний «мало данных», «нет данных», «источник недоступен» на непротиворечивость.
- [x] Вынести шкалу активности и пользовательские формулировки в общий helper.
- [x] Устранить противоречие карточки, sidebar и detail для всех диапазонов 0–100.
- [x] Добавить helper русских числительных и unit-тесты граничных значений.
- [x] Проверить тексты состояний «мало данных», «нет данных», «источник недоступен» на непротиворечивость.
Критерий: одно значение во всех представлениях имеет одинаковый уровень и объяснение; в UI нет строк вида `1 точки`.