Compare commits

...
11 Commits
Author SHA1 Message Date
ik 51b3eb5e17 docs: update REGRESSION_FIXES_REPORT.md with R03-R08 fixes (#1788956171115)
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-10 05:39:13 +07:00
ik 38bb871638 fix: R08 filter-compact-hidden display:none + details fallback for no-JS (#1788956171115) 2026-09-10 05:39:06 +07:00
ik 0b3a5ece4b fix: R06 community_observations filters by fish/waterbody slug via JOIN (#1788956171115) 2026-09-10 05:38:22 +07:00
ik fc878c81d4 fix: R04 site cooldown uses full registry for disabled sources; R05 remove proxy/scheduler from bootstrap (#1788956171115) 2026-09-10 05:37:38 +07:00
ik ff0bc08222 fix: R03 research CLI cooldown — _read_state/_write_state helpers, handle missing file (#1788956171115) 2026-09-09 21:23:29 +07:00
ik d962ba2f90 fix: R02 activity API contract — PaginatedActivity + all consumers (#1788956171115) 2026-09-09 20:58:42 +07:00
ik a37f9c4696 fix: R01 Caddy body_limit → request_body max_size (Caddy 2.10.2 compat) (#1788956171115) 2026-09-09 20:35:36 +07:00
ik cefb4494a5 docs: audit regressions and prioritize recovery plan 2026-09-09 20:22:50 +07:00
ik 9ae05ef6f0 fix: S01 HTTP/SEO contract — noindex for errors, 422/503 status codes (#1788956171115) 2026-09-09 20:14:10 +07:00
ik b31042337b fix: U03 pagination + U04 form errors with retry (#1788956171115) 2026-09-09 20:08:20 +07:00
ik 63e33e1861 fix: audit P0-P1 — T03-T07, D01-D08, U01-U02 (#1788956171115) 2026-09-09 20:02:52 +07:00
29 changed files with 660 additions and 113 deletions
+3 -1
View File
@@ -6,6 +6,8 @@ RF4 Spotter — неофициальный сервис свежих точек
## Статус разработки ## Статус разработки
**Повторная приёмка 9 сентября 2026 (`9ae05ef`): к деплою пока не готов.** Найдены регрессии после последних исправлений: невалидный Caddyfile, несовместимость activity API с detail-страницами, поломка research cooldown CLI и незавершённые UI/SEO/защита интервалов. Python: **10 failed, 86 passed, 1 skipped**; Astro check/build и web unit проходят, но не покрывают эти сценарии. [Отчёт с доказательствами](docs/REGRESSION_AUDIT_2026-09-09.md), [план R01R15](docs/ROADMAP.md#повторная-приёмка-9-сентября-2026). Следующие задачи — R01 и R02. До R05 не запускать текущий production bootstrap: он наследует реальные источники и публичные порты. Ниже описаны реализованные возможности; прежние успешные проверки не означают приёмку текущей ревизии.
Web Docker-образ устанавливает зависимости через `npm ci` по lock-файлу и удаляет devDependencies после сборки. Локальные `.env` исключены из web build context. Web Docker-образ устанавливает зависимости через `npm ci` по lock-файлу и удаляет devDependencies после сборки. Локальные `.env` исключены из web build context.
Очередь внешних наблюдений выбирает только ожидающие проверки записи на сервере и показывает их страницами по 50. Обработанные записи не скрывают более старые необработанные наблюдения. Очередь внешних наблюдений выбирает только ожидающие проверки записи на сервере и показывает их страницами по 50. Обработанные записи не скрывают более старые необработанные наблюдения.
@@ -18,7 +20,7 @@ Web Docker-образ устанавливает зависимости чере
Идёт исправление аудита: актуальные изменения и ограничения перечислены в [AUDIT_FIXES.md](docs/AUDIT_FIXES.md). Production Compose включает community scheduler; страницы rules/privacy реализованы. Для запуска остаются сервер, DNS/TLS, секреты, внешний backup и контакты. Шкала 72 часов использует полную выборку по времени поступления; одинаковые поля разных источников больше не считаются доказательством одного события. Фоновая публикация обновляет кэш API в пределах TTL, не мгновенно. Идёт исправление аудита: актуальные изменения и ограничения перечислены в [AUDIT_FIXES.md](docs/AUDIT_FIXES.md). Production Compose включает community scheduler; страницы rules/privacy реализованы. Для запуска остаются сервер, DNS/TLS, секреты, внешний backup и контакты. Шкала 72 часов использует полную выборку по времени поступления; одинаковые поля разных источников больше не считаются доказательством одного события. Фоновая публикация обновляет кэш API в пределах TTL, не мгновенно.
Полный аудит 8 сентября: [отчёт](docs/PROJECT_AUDIT_2026-09-08.md), [приоритетный план](docs/ROADMAP.md#ближайший-рабочий-пакет). T01/T02 выполнены: исправлены маршруты формы и конфликт admin-аутентификации. Проверка `sh deploy/test-proxy-routing.sh` на запущенном локальном Compose проверяет Astro redirects, API, Basic-доступ к страницам и Bearer-доступ к API, включая отказы. Следующий блокер — исходящая сеть community scheduler (T03). Затем — клиентский rate limit, надёжность парсеров, согласованность фильтров/оценок и HTTP/SEO-контракт ошибок. Предыдущий полный аудит 8 сентября: [отчёт](docs/PROJECT_AUDIT_2026-09-08.md). T01/T02 исправили маршруты формы и конфликт admin-аутентификации; `sh deploy/test-proxy-routing.sh` проверял Astro redirects, API, Basic/Bearer и отказы на прежней ревизии. На текущей ревизии повторный запуск блокирует R01. Подключение scheduler к исходящей сети добавлено, но интеграционная приёмка T03 ещё нужна. Актуальная последовательность работ находится в [плане повторной приёмки](docs/ROADMAP.md#повторная-приёмка-9-сентября-2026).
На ширинах 320, 390, 768 и 1280 px ранее проверено отсутствие горизонтального переполнения основных страниц. Это не полная визуальная приёмка: аудит обнаружил неверную desktop-компоновку фильтров; наполненные карточки, длинные названия, клавиатура и zoom остаются отдельной задачей. На ширинах 320, 390, 768 и 1280 px ранее проверено отсутствие горизонтального переполнения основных страниц. Это не полная визуальная приёмка: аудит обнаружил неверную desktop-компоновку фильтров; наполненные карточки, длинные названия, клавиатура и zoom остаются отдельной задачей.
+1 -1
View File
@@ -56,7 +56,7 @@ def activity_rows(
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3)) activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
average_confidence = sum(r.source_confidence for r in items) / len(items) average_confidence = sum(r.source_confidence for r in items) / len(items)
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100) confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
latest = max(_aware(r.caught_at or r.reported_at) for r in items) latest = max(_aware(r.reported_at) for r in items)
baits = Counter(r.bait.name for r in items if r.bait) baits = Counter(r.bait.name for r in items if r.bait)
freshness_text = _freshness_text(now - latest) freshness_text = _freshness_text(now - latest)
result.append(ActivityOut( result.append(ActivityOut(
+20 -9
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .community_review import publish_observation from .community_review import publish_observation
from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus, Waterbody
SOURCE_DEFAULTS = { SOURCE_DEFAULTS = {
@@ -107,7 +107,6 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
observation.status not in {"staged", "mapped", "ready"} observation.status not in {"staged", "mapped", "ready"}
or not observation.source.enabled or not observation.source.enabled
or observation.fish_external_id is None or observation.fish_external_id is None
or observation.waterbody_external_id is None
or observation.x is None or observation.x is None
or observation.y is None or observation.y is None
or observation.weight_g is None or observation.weight_g is None
@@ -118,15 +117,27 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
ExternalEntityAlias.entity_type == "fish", ExternalEntityAlias.entity_type == "fish",
ExternalEntityAlias.external_id == observation.fish_external_id, ExternalEntityAlias.external_id == observation.fish_external_id,
)) ))
waterbody_alias = session.scalar(select(ExternalEntityAlias).where( if fish_alias is None or fish_alias.fish is None:
ExternalEntityAlias.source_system == observation.source_system, return False
ExternalEntityAlias.entity_type == "waterbody", # Waterbody: prefer external alias, fall back to exact name match
ExternalEntityAlias.external_id == observation.waterbody_external_id, waterbody = None
)) if observation.waterbody_external_id is not None:
if fish_alias is None or fish_alias.fish is None or waterbody_alias is None or waterbody_alias.waterbody is None: waterbody_alias = session.scalar(select(ExternalEntityAlias).where(
ExternalEntityAlias.source_system == observation.source_system,
ExternalEntityAlias.entity_type == "waterbody",
ExternalEntityAlias.external_id == observation.waterbody_external_id,
))
if waterbody_alias and waterbody_alias.waterbody:
waterbody = waterbody_alias.waterbody
if waterbody is None:
# Fallback: exact name match
waterbody = session.scalar(
select(Waterbody).where(Waterbody.name_ru == observation.waterbody_name)
)
if waterbody is None:
return False return False
observation.fish = fish_alias.fish observation.fish = fish_alias.fish
observation.waterbody = waterbody_alias.waterbody observation.waterbody = waterbody
observation.status = "ready" observation.status = "ready"
observation.review_note = "Automatically matched by previously reviewed source aliases" observation.review_note = "Automatically matched by previously reviewed source aliases"
publish_observation(session, observation) publish_observation(session, observation)
+40 -9
View File
@@ -26,7 +26,9 @@ def retry_delay(statuses: list[str]) -> int:
failures += 1 failures += 1
return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS) return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS)
def configured_sources():
def _static_registry() -> dict[str, tuple[str, callable]]:
"""Return the full static registry without DB access (for unit tests)."""
return { return {
"rf4db": SOURCES["rf4db"], "rf4db": SOURCES["rf4db"],
"rf4stat-fishing": SOURCES["rf4stat-fishing"], "rf4stat-fishing": SOURCES["rf4stat-fishing"],
@@ -35,18 +37,47 @@ def configured_sources():
"rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot), "rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot),
} }
def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime]) -> str:
sources = configured_sources() def configured_sources(enabled_keys: set[str] | None = None) -> dict[str, tuple[str, callable]]:
site = fetch_site_key(sources[source_system][0]) """Return enabled sources. When enabled_keys is None, query the DB."""
candidates = [key for key, (url, _) in sources.items() if fetch_site_key(url) == site] registry = _static_registry()
if enabled_keys is None:
with SessionLocal() as session:
enabled_keys = {
s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True)))
}
return {k: v for k, v in registry.items() if k in enabled_keys}
def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime], enabled_keys: set[str] | None = None) -> str:
"""Return the oldest candidate for the same site.
Uses the full registry (not just enabled) for cooldown history so that
disabling an endpoint does not reset the site-wide cooldown for its
neighbours. enabled_keys is used to filter candidates after the oldest
is found — if the oldest is disabled, the next oldest enabled is picked.
"""
registry = _static_registry()
site = fetch_site_key(registry[source_system][0])
candidates = [key for key, (url, _) in registry.items() if fetch_site_key(url) == site]
order = {key: index for index, key in enumerate(candidates)} order = {key: index for index, key in enumerate(candidates)}
return min(candidates, key=lambda key: (latest_by_source.get(key, datetime.min.replace(tzinfo=timezone.utc)), order[key])) # Sort by (last_run, order) — pick oldest
sorted_candidates = sorted(candidates, key=lambda key: (latest_by_source.get(key, datetime.min.replace(tzinfo=timezone.utc)), order[key]))
# If enabled_keys is provided, prefer enabled; otherwise return oldest regardless
if enabled_keys:
enabled = [k for k in sorted_candidates if k in enabled_keys]
if enabled:
return enabled[0]
return sorted_candidates[0]
def run_source(source_system: str, *, now: datetime | None = None) -> bool: def run_source(source_system: str, *, now: datetime | None = None) -> bool:
current = now or datetime.now(timezone.utc) current = now or datetime.now(timezone.utc)
url, parser = configured_sources()[source_system] with SessionLocal() as session:
enabled_keys = {s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True)))}
registry = _static_registry()
url, parser = registry[source_system]
site_key = fetch_site_key(url) site_key = fetch_site_key(url)
site_sources = [key for key, (candidate_url, _) in configured_sources().items() if fetch_site_key(candidate_url) == site_key] site_sources = [key for key, (candidate_url, _) in registry.items() if fetch_site_key(candidate_url) == site_key]
with SessionLocal() as session: with SessionLocal() as session:
source = session.get(DataSource, source_system) source = session.get(DataSource, source_system)
if source is None or not source.enabled: if source is None or not source.enabled:
@@ -59,7 +90,7 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
latest_by_source: dict[str, datetime] = {} latest_by_source: dict[str, datetime] = {}
for previous in recent: for previous in recent:
latest_by_source.setdefault(previous.source_system, previous.started_at if previous.started_at.tzinfo else previous.started_at.replace(tzinfo=timezone.utc)) latest_by_source.setdefault(previous.source_system, previous.started_at if previous.started_at.tzinfo else previous.started_at.replace(tzinfo=timezone.utc))
if oldest_site_source(source_system, latest_by_source) != source_system: if oldest_site_source(source_system, latest_by_source, enabled_keys) != source_system:
return False return False
latest = recent[0].started_at if recent else None latest = recent[0].started_at if recent else None
delay = retry_delay([run.status for run in recent]) delay = retry_delay([run.status for run in recent])
+26 -19
View File
@@ -27,7 +27,7 @@ from .logging_config import configure_logging
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report from .readiness import readiness_report
from .public_cache import public_cache from .public_cache import public_cache
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PublicObservationOut, SourceStatusOut, 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
@@ -87,6 +87,7 @@ def ready(db: Db) -> JSONResponse:
is_ready, components = readiness_report( is_ready, components = readiness_report(
db, storage_client(), import_required=settings.official_import_required, db, storage_client(), import_required=settings.official_import_required,
import_interval_seconds=settings.import_interval_seconds, import_interval_seconds=settings.import_interval_seconds,
community_import_interval_seconds=settings.community_import_interval_seconds,
) )
return JSONResponse( return JSONResponse(
status_code=200 if is_ready else 503, status_code=200 if is_ready else 503,
@@ -120,14 +121,14 @@ def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int
(f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")] (f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")]
@app.get("/api/v1/activity", response_model=list[ActivityOut]) @app.get("/api/v1/activity", response_model=PaginatedActivityOut)
def activity( def activity(
db: Db, response: Response, hours: int = Query(24), db: Db, response: Response, hours: int = Query(24),
waterbody: str | None = None, fish: str | None = None, waterbody: str | None = None, fish: str | None = None,
method: str | None = None, method: str | None = None,
sort: Literal["activity", "confidence", "freshness"] = "activity", sort: Literal["activity", "confidence", "freshness"] = "activity",
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
) -> list[ActivityOut]: ) -> PaginatedActivityOut:
if hours not in {6, 12, 24, 72}: if hours not in {6, 12, 24, 72}:
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72") raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
@@ -138,14 +139,16 @@ def activity(
response.headers["X-Cache"] = "HIT" response.headers["X-Cache"] = "HIT"
return cached return cached
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method) rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
total = len(rows)
keys = { keys = {
"activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)), "activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)),
"confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)), "confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)),
"freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)), "freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)),
} }
rows.sort(key=keys[sort], reverse=True) rows.sort(key=keys[sort], reverse=True)
page = rows[offset:offset + limit]
response.headers["X-Cache"] = "MISS" response.headers["X-Cache"] = "MISS"
return public_cache.set(cache_key, rows[offset:offset + limit], generation=generation) return public_cache.set(cache_key, PaginatedActivityOut(items=page, total=total, limit=limit, offset=offset), generation=generation)
def _spot_or_404(db: Session, spot_id: UUID) -> Spot: def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
@@ -218,19 +221,18 @@ def _report_source(report: CatchReport) -> str:
@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut]) @app.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
def community_observations( def community_observations(
db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0), db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0),
waterbody: str | None = None, fish: str | None = None,
) -> list[PublicObservationOut]: ) -> list[PublicObservationOut]:
items = list(db.scalars( query = select(ExternalObservation).join(ExternalObservation.source).options(joinedload(ExternalObservation.source)).where(
select(ExternalObservation) ExternalObservation.catch_report_id.is_(None),
.join(ExternalObservation.source) ExternalObservation.status != "rejected",
.options(joinedload(ExternalObservation.source)) DataSource.enabled.is_(True),
.where( )
ExternalObservation.catch_report_id.is_(None), if waterbody:
ExternalObservation.status != "rejected", query = query.join(ExternalObservation.waterbody).where(Waterbody.slug == waterbody)
DataSource.enabled.is_(True), if fish:
) query = query.join(ExternalObservation.fish).where(Fish.slug == fish)
.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()) items = list(db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit)))
.offset(offset).limit(limit)
))
result: list[PublicObservationOut] = [] result: list[PublicObservationOut] = []
for item in items: for item in items:
missing = [] missing = []
@@ -318,7 +320,7 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"}) return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
@app.get("/api/v1/imports", response_model=list[ImportRunOut]) @app.get("/api/v1/imports", response_model=list[ImportRunPublicOut])
def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]: def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]:
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit))) return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit)))
@@ -448,7 +450,7 @@ def admin_reject_external_observation(
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted: def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted:
if payload.website: if payload.website:
raise HTTPException(status_code=400, detail="invalid submission") raise HTTPException(status_code=400, detail="invalid submission")
_check_rate_limit(request.client.host if request.client else "unknown", db) _check_rate_limit(request, db)
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug)) fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug)) waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
if fish is None or waterbody is None: if fish is None or waterbody is None:
@@ -536,9 +538,14 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
return Response(status_code=204) return Response(status_code=204)
def _check_rate_limit(client: str, db: Session) -> None: def _check_rate_limit(request: Request, db: Session) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cutoff = now - timedelta(minutes=10) cutoff = now - timedelta(minutes=10)
# Extract real client IP from forwarded headers
client = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client = forwarded.split(",")[0].strip()
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest() client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
if db.get_bind().dialect.name == "postgresql": if db.get_bind().dialect.name == "postgresql":
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
+25 -2
View File
@@ -6,12 +6,13 @@ from typing import Any
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import ImportStatus, OfficialRecordImport from .models import CommunityImportRun, ImportStatus, OfficialRecordImport
def readiness_report( def readiness_report(
session: Session, s3: Any, *, import_required: bool, session: Session, s3: Any, *, import_required: bool,
import_interval_seconds: int, now: datetime | None = None, import_interval_seconds: int, community_import_interval_seconds: int = 1800,
now: datetime | None = None,
) -> tuple[bool, dict[str, dict[str, object]]]: ) -> tuple[bool, dict[str, dict[str, object]]]:
current = now or datetime.now(timezone.utc) current = now or datetime.now(timezone.utc)
components: dict[str, dict[str, object]] = {} components: dict[str, dict[str, object]] = {}
@@ -58,4 +59,26 @@ def readiness_report(
if import_required: if import_required:
ready = False ready = False
# Check community scheduler: look for recent import runs
try:
latest_community = session.scalar(
select(CommunityImportRun)
.order_by(CommunityImportRun.started_at.desc())
.limit(1)
)
if latest_community is None:
components["community_scheduler"] = {"status": "not_started"}
else:
started = latest_community.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
stale = started < current - timedelta(seconds=community_import_interval_seconds * 2)
healthy = latest_community.status == "success" and not stale
components["community_scheduler"] = {
"status": "ready" if healthy else ("stale" if stale else latest_community.status),
"last_started_at": started.isoformat(),
}
except Exception:
components["community_scheduler"] = {"status": "unknown"}
return ready, components return ready, components
+19
View File
@@ -50,6 +50,13 @@ class ActivityOut(BaseModel):
sources: list[str] sources: list[str]
class PaginatedActivityOut(BaseModel):
items: list[ActivityOut]
total: int
limit: int
offset: int
class CatchOut(BaseModel): class CatchOut(BaseModel):
id: UUID id: UUID
fish: str fish: str
@@ -125,6 +132,18 @@ class ImportRunOut(BaseModel):
not_modified: bool not_modified: bool
class ImportRunPublicOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
started_at: datetime
finished_at: datetime | None
status: str
rows_seen: int
rows_created: int
rows_updated: int
not_modified: bool
class CatchReportCreate(BaseModel): class CatchReportCreate(BaseModel):
fish_slug: str fish_slug: str
waterbody_slug: str waterbody_slug: str
+12 -8
View File
@@ -45,11 +45,15 @@ def test_activity_filters_and_explains_score() -> None:
response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24") response = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24")
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert len(payload) == 1 assert "items" in payload
assert payload[0]["catches"] == 3 assert payload["total"] == 1
assert payload[0]["unique_players"] == 3 assert payload["limit"] == 20
assert "3 свежих улова" in payload[0]["explanation"] assert payload["offset"] == 0
assert payload[0]["sources"] == ["manual-import"] assert len(payload["items"]) == 1
assert payload["items"][0]["catches"] == 3
assert payload["items"][0]["unique_players"] == 3
assert "3 свежих улова" in payload["items"][0]["explanation"]
assert payload["items"][0]["sources"] == ["manual-import"]
def test_invalid_period_is_rejected() -> None: def test_invalid_period_is_rejected() -> None:
@@ -157,7 +161,7 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
def test_spot_detail_and_catches() -> None: def test_spot_detail_and_catches() -> None:
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"] spot_id = client.get("/api/v1/activity").json()["items"][0]["spot_id"]
detail = client.get(f"/api/v1/spots/{spot_id}") detail = client.get(f"/api/v1/spots/{spot_id}")
catches = client.get(f"/api/v1/spots/{spot_id}/catches") catches = client.get(f"/api/v1/spots/{spot_id}/catches")
assert detail.status_code == 200 assert detail.status_code == 200
@@ -206,7 +210,7 @@ def test_user_report_requires_moderation_before_activity() -> None:
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"}) approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
assert approved.status_code == 200 assert approved.status_code == 200
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json() activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity) assert any(item["x"] == 77 and item["catches"] == 1 for item in activity["items"])
def test_admin_requires_token() -> None: def test_admin_requires_token() -> None:
@@ -267,7 +271,7 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
assert signal["source_system"] == "rf4db" assert signal["source_system"] == "rf4db"
assert signal["quality"] == "incomplete" assert signal["quality"] == "incomplete"
assert signal["missing_fields"] == ["вес"] assert signal["missing_fields"] == ["вес"]
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()) assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
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,
+5 -4
View File
@@ -3,12 +3,12 @@ from pydantic import ValidationError
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, oldest_site_source, retry_delay from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
from app.config import Settings from app.config import Settings
def test_all_authorized_sources_are_scheduled() -> None: def test_all_authorized_sources_are_scheduled() -> None:
assert set(configured_sources()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"} assert set(_static_registry()) == {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
def test_community_interval_cannot_be_less_than_30_minutes() -> None: def test_community_interval_cannot_be_less_than_30_minutes() -> None:
@@ -25,6 +25,7 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None: def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
assert oldest_site_source("rf4stat-fishing", {}) == "rf4stat-fishing" all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
assert oldest_site_source("rf4stat-fishing", {}, all_keys) == "rf4stat-fishing"
latest = {"rf4stat-fishing": now, "rf4stat-post": now - timedelta(hours=1)} latest = {"rf4stat-fishing": now, "rf4stat-post": now - timedelta(hours=1)}
assert oldest_site_source("rf4stat-fishing", latest) == "rf4stat-post" assert oldest_site_source("rf4stat-fishing", latest, all_keys) == "rf4stat-post"
+26 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
@@ -17,11 +18,34 @@ def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
with Session(engine) as db: with Session(engine) as db:
for _ in range(5): for _ in range(5):
_check_rate_limit("203.0.113.42", db) mock_request = MagicMock()
mock_request.client.host = "203.0.113.42"
mock_request.headers.get.return_value = None
_check_rate_limit(mock_request, db)
with pytest.raises(HTTPException) as blocked: with pytest.raises(HTTPException) as blocked:
_check_rate_limit("203.0.113.42", db) mock_request = MagicMock()
mock_request.client.host = "203.0.113.42"
mock_request.headers.get.return_value = None
_check_rate_limit(mock_request, db)
assert blocked.value.status_code == 429 assert blocked.value.status_code == 429
attempts = list(db.scalars(select(SubmissionAttempt))) attempts = list(db.scalars(select(SubmissionAttempt)))
assert len(attempts) == 5 assert len(attempts) == 5
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts) assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts) assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
def test_rate_limit_uses_forwarded_for_header() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
mock_real = MagicMock()
mock_real.client.host = "10.0.0.1"
mock_real.headers.get.return_value = "198.51.100.10"
for _ in range(5):
_check_rate_limit(mock_real, db)
with pytest.raises(HTTPException) as blocked:
mock_other = MagicMock()
mock_other.client.host = "10.0.0.2"
mock_other.headers.get.return_value = "198.51.100.10"
_check_rate_limit(mock_other, db)
assert blocked.value.status_code == 429
+5 -5
View File
@@ -28,11 +28,11 @@ def test_optional_import_does_not_block_dependencies() -> None:
session, AvailableStorage(), import_required=False, import_interval_seconds=3600, session, AvailableStorage(), import_required=False, import_interval_seconds=3600,
) )
assert ready is True assert ready is True
assert components == { assert components["postgresql"]["status"] == "ready"
"postgresql": {"status": "ready"}, assert components["minio"]["status"] == "ready"
"minio": {"status": "ready"}, assert components["official_import"]["status"] == "optional"
"official_import": {"status": "optional", "last_run_status": None}, assert components["official_import"]["last_run_status"] is None
} assert "community_scheduler" in components
def test_required_import_must_be_recent_and_successful() -> None: def test_required_import_must_be_recent_and_successful() -> None:
+7
View File
@@ -6,6 +6,13 @@ export type Activity = {
explanation: string; sources: string[]; explanation: string; sources: string[];
}; };
export type PaginatedActivity = {
items: Activity[];
total: number;
limit: number;
offset: number;
};
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; source_system: string; source_url: string | 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 };
+1 -1
View File
@@ -11,7 +11,7 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
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); 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 { try {
const upload = new FormData(); upload.set("screenshot", screenshot); const upload = new FormData(); upload.set("screenshot", screenshot);
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload }); const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload, signal: AbortSignal.timeout(60_000) });
if (response.ok) cookies.delete(cookieName, {path:"/"}); if (response.ok) cookies.delete(cookieName, {path:"/"});
return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); 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); } } catch { return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); }
+18 -5
View File
@@ -1,15 +1,24 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000"; const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export const POST: APIRoute = async ({ request, redirect, cookies }) => { export const POST: APIRoute = async ({ request, redirect, cookies }) => {
const form = await request.formData(); let form: FormData;
try {
form = await request.formData();
} catch {
return redirect("/report?state=create_error", 303);
}
const text = (name: string) => String(form.get(name) || "").trim() || null; const text = (name: string) => String(form.get(name) || "").trim() || null;
const number = (name: string) => text(name) ? Number(text(name)) : 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") || "") }; 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 createdId: string | null = null;
let uploadToken: string | null = null; let uploadToken: string | null = null;
try { try {
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json", "X-Forwarded-For": request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000) });
if (!response.ok) return redirect("/report?state=create_error", 303); if (!response.ok) {
if (response.status === 429) return redirect("/report?state=rate_limited", 303);
if (response.status >= 500) return redirect("/report?state=server_error", 303);
return redirect("/report?state=create_error", 303);
}
const created = await response.json() as { id?: unknown; screenshot_upload_token?: unknown }; 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); if (typeof created.id !== "string" || typeof created.screenshot_upload_token !== "string") return redirect("/report?state=create_error", 303);
createdId = created.id; createdId = created.id;
@@ -17,7 +26,7 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
const screenshot = form.get("screenshot"); const screenshot = form.get("screenshot");
if (screenshot instanceof File && screenshot.size > 0) { if (screenshot instanceof File && screenshot.size > 0) {
const upload = new FormData(); upload.set("screenshot", screenshot); const upload = new FormData(); upload.set("screenshot", screenshot);
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload }); const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload, signal: AbortSignal.timeout(60_000) });
if (!uploaded.ok) { 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}); 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=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
@@ -25,7 +34,11 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
} }
return redirect("/report?state=sent", 303); return redirect("/report?state=sent", 303);
} }
catch { catch (err) {
if (err instanceof TypeError && err.message.includes("abort")) {
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=timeout", 303);
}
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600}); 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); return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=create_error", 303);
} }
+2 -2
View File
@@ -1,13 +1,13 @@
--- ---
import ActivityCard from "../../components/ActivityCard.astro"; import ActivityCard from "../../components/ActivityCard.astro";
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api"; import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
const { slug } = Astro.params; const { slug } = Astro.params;
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
try { try {
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500"); const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
fish = fishes.find(item => item.slug === slug); fish = fishes.find(item => item.slug === slug);
if (fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
} catch { unavailable = true; } } catch { unavailable = true; }
if (unavailable) { if (unavailable) {
Astro.response.status = 503; Astro.response.status = 503;
+26 -14
View File
@@ -4,7 +4,7 @@ import ActivityCard from "../components/ActivityCard.astro";
import FishingIcon from "../components/FishingIcon.astro"; import FishingIcon from "../components/FishingIcon.astro";
import SourceBadge from "../components/SourceBadge.astro"; import SourceBadge from "../components/SourceBadge.astro";
import SignalFeed from "../components/SignalFeed.astro"; import SignalFeed from "../components/SignalFeed.astro";
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PublicObservation } from "../lib/api"; import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, 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";
@@ -14,17 +14,24 @@ const sort = params.get("sort") ?? "activity";
const requestedSignalLimit = Number(params.get("signals") ?? 12); const requestedSignalLimit = Number(params.get("signals") ?? 12);
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12; const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let hasMoreSignals = false; let hasMoreSignals = false, totalItems = 0;
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, showNoIndex = false;
if (filterError) { showNoIndex = true; Astro.response.status = 422; }
if (unavailable) { showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
try { try {
let signalRows: PublicObservation[] = []; let signalRows: PublicObservation[] = [];
[fishes, waterbodies, signalRows] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>(`/api/v1/community-observations?limit=${signalLimit + 1}`)]); const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) });
if (waterbody) signalParams.set("waterbody", waterbody);
if (fish) signalParams.set("fish", fish);
[fishes, waterbodies, signalRows] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>(`/api/v1/community-observations?${signalParams}`)]);
hasMoreSignals = signalRows.length > signalLimit; hasMoreSignals = signalRows.length > signalLimit;
signals = signalRows.slice(0, signalLimit); signals = signalRows.slice(0, signalLimit);
if (!filterError) { if (!filterError) {
const query = new URLSearchParams({ hours, waterbody, fish, sort }); const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: "0" });
items = await api<Activity[]>(`/api/v1/activity?${query}`); const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
items = paginated.items;
totalItems = paginated.total;
} }
} catch { unavailable = true; } } catch { unavailable = true; }
const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null; const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null;
@@ -44,30 +51,35 @@ const datasetJsonLd = {
creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true, creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true,
}; };
--- ---
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd}> <Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex}>
<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.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><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="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.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><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" action="/#results"> <section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
<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="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> <label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
<details class="filter-advanced" open><summary>Период и порядок <span>{periodLabel}</span></summary><div class="advanced-fields"> <details class="filter-advanced-fallback" open><summary>Период и порядок</summary><div class="advanced-fields">
<label>Период<select name="hours"><option value="6" selected={hours === "6"}>6 часов</option><option value="12" selected={hours === "12"}>12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72" selected={hours === "72"}>72 часа</option></select></label> <label class="filter-advanced-field">Период<select name="hours"><option value="6">6 часов</option><option value="12">12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72">72 часа</option></select></label>
<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> <label class="filter-advanced-field">Сначала<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>
</div></details> </div></details>
<button>⌕ Найти клёв</button> <button>⌕ Найти клёв</button>
</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} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</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>{items.length < totalItems && <a class="load-more" href={`/?${new URLSearchParams({ ...Object.fromEntries(params), offset: String(items.length) }).toString()}#results`}>Показать ещё <span>{items.length} из {totalItems}</span> ↓</a>}</> : <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="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>} {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 && <SignalFeed signals={signals}/>} {signals.length > 0 && <SignalFeed signals={signals}/>}
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>} {hasMoreSignals && signalLimit < 48 && <a class="signal-more" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
<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 advancedFields = document.querySelectorAll<HTMLElement>(".filter-advanced-field");
const compactFilters = window.matchMedia("(max-width: 720px)"); const compactFilters = window.matchMedia("(max-width: 720px)");
const syncFilters = (event: MediaQueryList | MediaQueryListEvent) => { const syncFilters = (event: MediaQueryList | MediaQueryListEvent) => {
if (event.matches) advancedFilters?.removeAttribute("open"); advancedFields.forEach(field => {
else advancedFilters?.setAttribute("open", ""); if (event.matches) {
field.classList.add("filter-compact-hidden");
} else {
field.classList.remove("filter-compact-hidden");
}
});
}; };
syncFilters(compactFilters); syncFilters(compactFilters);
compactFilters.addEventListener("change", syncFilters); compactFilters.addEventListener("change", syncFilters);
+3 -3
View File
@@ -5,11 +5,11 @@ import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } fro
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
const fish = params.get("fish") ?? ""; const fish = params.get("fish") ?? "";
const waterbody = params.get("waterbody") ?? ""; const waterbody = params.get("waterbody") ?? "";
let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false; let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false, showNoIndex = false;
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; } try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
const last = runs[0]; const last = runs[0];
--- ---
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник."> <Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex}>
<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>
+9 -1
View File
@@ -11,6 +11,9 @@ const reportId = Astro.url.searchParams.get("report_id");
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>} {state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>} {state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
{state === "create_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Заявка не создана.</strong> Проверьте поля; введённые значения сохранены.</div>} {state === "create_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Заявка не создана.</strong> Проверьте поля; введённые значения сохранены.</div>}
{state === "rate_limited" && <div class="notice error" id="form-error" tabindex="-1"><strong>Слишком много отправок.</strong> Подождите 10 минут перед повторной попыткой.</div>}
{state === "timeout" && <div class="notice error" id="form-error" tabindex="-1"><strong>Превышено время ожидания.</strong> Проверьте соединение и попробуйте снова.</div>}
{state === "server_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Сервер временно недоступен.</strong> Попробуйте позже.</div>}
{state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>} {state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>}
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button type="submit">Загрузить скриншот</button></form>} {state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button type="submit">Загрузить скриншот</button></form>}
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data"> {unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
@@ -21,6 +24,11 @@ const reportId = Astro.url.searchParams.get("report_id");
const form = document.querySelector(".report-form"); const key = "rf4-report-draft"; const form = document.querySelector(".report-form"); const key = "rf4-report-draft";
if (form && state === "create_error") { try { const draft = JSON.parse(sessionStorage.getItem(key) || "{}"); for (const [name, value] of Object.entries(draft)) { const field = form.elements.namedItem(name); if (field && "value" in field) field.value = value; } } catch {} document.querySelector("#form-error")?.focus(); } if (form && state === "create_error") { try { const draft = JSON.parse(sessionStorage.getItem(key) || "{}"); for (const [name, value] of Object.entries(draft)) { const field = form.elements.namedItem(name); if (field && "value" in field) field.value = value; } } catch {} document.querySelector("#form-error")?.focus(); }
if (state === "sent") sessionStorage.removeItem(key); if (state === "sent") sessionStorage.removeItem(key);
form?.addEventListener("submit", () => { const draft = {}; for (const [name, value] of new FormData(form)) if (typeof value === "string" && name !== "website") draft[name] = value; sessionStorage.setItem(key, JSON.stringify(draft)); }); form?.addEventListener("submit", () => {
const btn = form.querySelector("button[type=submit]");
if (btn) { btn.disabled = true; btn.textContent = "Отправка..."; }
const draft = {}; for (const [name, value] of new FormData(form)) if (typeof value === "string" && name !== "website") draft[name] = value;
sessionStorage.setItem(key, JSON.stringify(draft));
});
</script> </script>
</Layout> </Layout>
+3 -3
View File
@@ -4,7 +4,7 @@ import SourceBadge from "../../components/SourceBadge.astro";
import CoordinateRadar from "../../components/CoordinateRadar.astro"; import CoordinateRadar from "../../components/CoordinateRadar.astro";
import ActivityTimeline from "../../components/ActivityTimeline.astro"; import ActivityTimeline from "../../components/ActivityTimeline.astro";
import CatchList from "../../components/CatchList.astro"; import CatchList from "../../components/CatchList.astro";
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type Spot } from "../../lib/api"; import { activityLevel, api, ApiError, plural, type Activity, type Catch, type PaginatedActivity, 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;
let timeline: { start: string; end: string; count: number }[] = []; let timeline: { start: string; end: string; count: number }[] = [];
@@ -14,8 +14,8 @@ try {
? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`) ? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`)
: await api<Spot>(`/api/v1/spots/${id}`); : await api<Spot>(`/api/v1/spots/${id}`);
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301); if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
const [catchResult, activityRows] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]); const [catchResult, activityPaginated] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<PaginatedActivity>(`/api/v1/activity?hours=24&waterbody=${encodeURIComponent(spotResult.waterbody_slug)}&limit=100`)]);
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null; spot = spotResult; catches = catchResult; activity = activityPaginated.items.find(item => item.spot_id === spotResult.id) ?? null;
timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`); timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`);
} catch (error) { } catch (error) {
unavailable = true; unavailable = true;
+2 -2
View File
@@ -1,13 +1,13 @@
--- ---
import ActivityCard from "../../components/ActivityCard.astro"; import ActivityCard from "../../components/ActivityCard.astro";
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api"; import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
const { slug } = Astro.params; const { slug } = Astro.params;
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
try { try {
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"); const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
water = waters.find(item => item.slug === slug); water = waters.find(item => item.slug === slug);
if (water) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
} catch { unavailable = true; } } catch { unavailable = true; }
if (unavailable) { if (unavailable) {
Astro.response.status = 503; Astro.response.status = 503;
@@ -1,13 +1,13 @@
--- ---
import ActivityCard from "../../../components/ActivityCard.astro"; import ActivityCard from "../../../components/ActivityCard.astro";
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import { api, plural, type Activity, type DictionaryItem } from "../../../lib/api"; import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../../lib/api";
const { slug, fish: fishSlug } = Astro.params; const { slug, fish: fishSlug } = Astro.params;
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
try { try {
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]); const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug); water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
if (water && fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`); if (water && fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
} catch { unavailable = true; } } catch { unavailable = true; }
if (unavailable) { if (unavailable) {
Astro.response.status = 503; Astro.response.status = 503;
+3 -2
View File
@@ -6,7 +6,7 @@
.skip-link{position:fixed;z-index:100;left:12px;top:12px;padding:12px 16px;background:var(--lime);color:var(--deep);font-weight:750;transform:translateY(-150%)}.skip-link:focus{transform:none}:focus-visible{outline:3px solid #7da529;outline-offset:3px}.topbar nav a[aria-current="page"]{font-weight:750}.notice:focus{outline:3px solid #9d3529;outline-offset:3px} .skip-link{position:fixed;z-index:100;left:12px;top:12px;padding:12px 16px;background:var(--lime);color:var(--deep);font-weight:750;transform:translateY(-150%)}.skip-link:focus{transform:none}:focus-visible{outline:3px solid #7da529;outline-offset:3px}.topbar nav a[aria-current="page"]{font-weight:750}.notice:focus{outline:3px solid #9d3529;outline-offset:3px}
.topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20} .topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
.eyebrow,.overline{text-transform:uppercase;letter-spacing:.14em;font-size:12px;font-weight:750;color:#657673}.eyebrow{display:flex;align-items:center;gap:10px}.eyebrow b{display:grid;place-items:center;min-width:41px;height:25px;padding:0 8px;border:1px solid #9aaa9f;border-radius:20px;color:var(--deep);letter-spacing:.05em}.intro{padding:74px 0 42px;display:grid;grid-template-columns:.92fr 1.08fr;align-items:end;gap:70px}.intro h1,.records-hero h1,.form-hero h1,.spot-hero h1{margin:18px 0 22px;font:400 clamp(58px,6.3vw,102px)/.87 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.intro h1 em,.records-hero h1 em,.form-hero h1 em{color:#497074;font-weight:400}.intro-copy>p{font-size:18px;line-height:1.55;color:#586a68;max-width:560px}.lake-card{position:relative;border-radius:18px;overflow:hidden;height:326px;box-shadow:0 24px 70px #173c3f24}.lake-card img{width:100%;height:100%;object-fit:cover;display:block}.lake-card:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,#041c20d9)}.lake-overlay{position:absolute;z-index:2;inset:auto 24px 22px 26px;display:flex;justify-content:space-between;align-items:end;color:#fff}.lake-overlay .overline{color:#d2dedb;display:block;margin-bottom:7px}.lake-overlay strong{font:400 21px Georgia,serif}.pulse-orb{width:45px;height:45px;border:1px solid #ffffff7d;border-radius:50%;display:grid;place-items:center}.pulse-orb span{width:9px;height:9px;border-radius:50%;background:var(--lime);box-shadow:0 0 0 8px #c9f45b25} .eyebrow,.overline{text-transform:uppercase;letter-spacing:.14em;font-size:12px;font-weight:750;color:#657673}.eyebrow{display:flex;align-items:center;gap:10px}.eyebrow b{display:grid;place-items:center;min-width:41px;height:25px;padding:0 8px;border:1px solid #9aaa9f;border-radius:20px;color:var(--deep);letter-spacing:.05em}.intro{padding:74px 0 42px;display:grid;grid-template-columns:.92fr 1.08fr;align-items:end;gap:70px}.intro h1,.records-hero h1,.form-hero h1,.spot-hero h1{margin:18px 0 22px;font:400 clamp(58px,6.3vw,102px)/.87 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.intro h1 em,.records-hero h1 em,.form-hero h1 em{color:#497074;font-weight:400}.intro-copy>p{font-size:18px;line-height:1.55;color:#586a68;max-width:560px}.lake-card{position:relative;border-radius:18px;overflow:hidden;height:326px;box-shadow:0 24px 70px #173c3f24}.lake-card img{width:100%;height:100%;object-fit:cover;display:block}.lake-card:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,#041c20d9)}.lake-overlay{position:absolute;z-index:2;inset:auto 24px 22px 26px;display:flex;justify-content:space-between;align-items:end;color:#fff}.lake-overlay .overline{color:#d2dedb;display:block;margin-bottom:7px}.lake-overlay strong{font:400 21px Georgia,serif}.pulse-orb{width:45px;height:45px;border:1px solid #ffffff7d;border-radius:50%;display:grid;place-items:center}.pulse-orb span{width:9px;height:9px;border-radius:50%;background:var(--lime);box-shadow:0 0 0 8px #c9f45b25}
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}.filter-advanced{display:contents}.filter-advanced summary{display:none}.advanced-fields{display:contents!important}.active-filters{min-height:56px;display:flex;align-items:center;gap:8px;padding-top:10px}.active-filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}.active-filters a{margin-left:auto;color:#4d625e;font-size:13px;text-underline-offset:3px} .filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}.filter-advanced-field{display:contents}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:contents}.filter-advanced-fallback summary{display:none}.filter-advanced-fallback .advanced-fields{display:contents}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.active-filters{min-height:56px;display:flex;align-items:center;gap:8px;padding-top:10px}.active-filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}.active-filters a{margin-left:auto;color:#4d625e;font-size:13px;text-underline-offset:3px}
.dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c} .dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c}
.detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff} .detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff}
.data-quality{padding:4px 8px;border-radius:20px;background:#fff0c7;color:#73550c;font-size:10px;font-weight:750}.data-note{margin:12px 0 0;color:#667875;font-size:12px;line-height:1.45}.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.state p{max-width:560px;line-height:1.5}.error-state{border-color:#d8aaa1}.error-state a{color:#842f25}.compact-state{min-height:180px}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572} .data-quality{padding:4px 8px;border-radius:20px;background:#fff0c7;color:#73550c;font-size:10px;font-weight:750}.data-note{margin:12px 0 0;color:#667875;font-size:12px;line-height:1.45}.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.state p{max-width:560px;line-height:1.5}.error-state{border-color:#d8aaa1}.error-state a{color:#842f25}.compact-state{min-height:180px}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572}
@@ -15,7 +15,7 @@
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55} .moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)} footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}} @media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced summary::-webkit-details-marker{display:none}.filter-advanced summary:before{content:"";margin-right:7px;color:var(--lime)}.filter-advanced[open] summary:before{content:""}.filter-advanced summary span{margin-left:auto;color:#9fb0ad}.advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}} @media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:""}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
@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: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 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(max-width:480px){.filters{grid-template-columns:1fr 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}} @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
@@ -37,4 +37,5 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
.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} .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: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}} @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}}
.load-more,.signal-more{display:inline-flex;align-items:center;gap:8px;padding:12px 20px;border:1px solid #c8d3cb;border-radius:10px;background:#f8faf5;color:#526662;font-size:14px;text-decoration:none;transition:.2s ease}.load-more:hover,.signal-more:hover{background:#eef2ec;border-color:#a8b8af}.load-more span,.signal-more span{color:#82908e;font-size:13px}
.not-found{min-height:calc(100vh - 204px);display:flex;flex-direction:column;align-items:flex-start;justify-content:center;padding-block:70px}.not-found__float{width:130px;height:130px;display:grid;place-items:center;position:relative;margin-bottom:28px;border:1px solid #b8c8be;border-radius:50%;color:#55751e;box-shadow:0 0 0 16px #dce7d722}.not-found__float strong{position:absolute;right:-18px;bottom:5px;padding:5px 9px;border-radius:20px;background:var(--deep);color:var(--lime);font:italic 14px Georgia,serif}.not-found h1{margin:14px 0 22px;font:400 clamp(58px,8vw,105px)/.88 Georgia,serif;letter-spacing:-.06em}.not-found h1 em{color:#497074;font-weight:400}.not-found p{max-width:600px;color:#4d605d;font-size:17px;line-height:1.6}.not-found>a{display:flex;align-items:center;gap:10px;margin-top:18px;padding:14px 18px;border-radius:10px;background:var(--deep);color:#fff;font-weight:750;text-decoration:none}.not-found>a .fish-icon{color:var(--lime)} .not-found{min-height:calc(100vh - 204px);display:flex;flex-direction:column;align-items:flex-start;justify-content:center;padding-block:70px}.not-found__float{width:130px;height:130px;display:grid;place-items:center;position:relative;margin-bottom:28px;border:1px solid #b8c8be;border-radius:50%;color:#55751e;box-shadow:0 0 0 16px #dce7d722}.not-found__float strong{position:absolute;right:-18px;bottom:5px;padding:5px 9px;border-radius:20px;background:var(--deep);color:var(--lime);font:italic 14px Georgia,serif}.not-found h1{margin:14px 0 22px;font:400 clamp(58px,8vw,105px)/.88 Georgia,serif;letter-spacing:-.06em}.not-found h1 em{color:#497074;font-weight:400}.not-found p{max-width:600px;color:#4d605d;font-size:17px;line-height:1.6}.not-found>a{display:flex;align-items:center;gap:10px;margin-top:18px;padding:14px 18px;border-radius:10px;background:var(--deep);color:#fff;font-weight:750;text-decoration:none}.not-found>a .fish-icon{color:var(--lime)}
+1 -1
View File
@@ -191,7 +191,7 @@ services:
depends_on: depends_on:
api: {condition: service_healthy} api: {condition: service_healthy}
command: ["python", "-m", "app.community_scheduler"] command: ["python", "-m", "app.community_scheduler"]
networks: [backend] networks: [backend, edge]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging logging: *default-logging
deploy: deploy:
+3
View File
@@ -5,6 +5,9 @@
{$SITE_DOMAIN} { {$SITE_DOMAIN} {
encode zstd gzip encode zstd gzip
request_body {
max_size 10M
}
@immutable path /_astro/* @immutable path /_astro/*
header @immutable Cache-Control "public, max-age=31536000, immutable" header @immutable Cache-Control "public, max-age=31536000, immutable"
+1 -1
View File
@@ -39,7 +39,7 @@ fail() {
} }
running=$($compose ps --status running --services 2>/dev/null) || running="" running=$($compose ps --status running --services 2>/dev/null) || running=""
for service in proxy db minio api web; do for service in proxy db minio api web community-scheduler; do
printf '%s\n' "$running" | grep -qx "$service" || fail "service:$service" printf '%s\n' "$running" | grep -qx "$service" || fail "service:$service"
done done
+144
View File
@@ -0,0 +1,144 @@
# Повторный аудит после исправлений — 9 сентября 2026
## Вывод и границы проверки
Текущий `9ae05ef` не готов к production. Проверены изменения `c6fdc96..9ae05ef`: три коммита, 19 файлов, 208 добавленных и 80 удалённых строк. Изменений тестов, README и ROADMAP в этом диапазоне нет. Поэтому по доступной ветке нельзя подтвердить выполнение всего предыдущего аудита: часть исправлений полезна, часть неполна, есть новые блокирующие регрессии.
Это проверка кода и локальных команд, а не повторная визуальная приёмка в браузере или проверка рабочего сервера. Парсеры в сеть не запускались: разрешённый интервал не менее 30 минут на площадку сохраняется. Production/bootstrap не запускался; для Caddy использован только одноразовый контейнер без сети. Неотслеживаемые `.gigacode/`, `.gigaide/`, `.idea/` не изменялись.
### Фактические проверки
| Проверка | Результат |
| --- | --- |
| `.venv/bin/pytest -q --tb=short` | **10 failed, 86 passed, 1 skipped**, 3,54 с |
| `.venv/bin/pytest -q tests/test_community_cli.py --tb=short` | **2 failed, 1 passed** |
| `npm --prefix apps/web run check` | 40 файлов, 0 ошибок/предупреждений |
| `npm --prefix apps/web run test:unit` | 1 успешный test-file subtest |
| `npm --prefix apps/web run build` | Успех |
| Caddy 2.10.2, `caddy adapt` с текущим Caddyfile | **Ошибка: строка 8, unrecognized directive: body_limit** |
Из 10 падений Python: два относятся к research cooldown; четыре — к изменённому activity-контракту; два — к новой зависимости scheduler helpers от внешней БД; по одному — к изменению сигнатуры rate limit и состава readiness. Последние два сами по себе не доказывают поломку функциональности: старые проверки не обновлены под новый контракт. Но зелёной приёмки изменений нет. Успешная сборка Astro не проверяет соответствие JSON реальному типу `api<T>`.
## Подтверждённые регрессии и недоработки
### R01 · P0 · Production proxy не может запуститься
- Доказательство: [Caddyfile](../deploy/Caddyfile), строка 8, `body_limit 10M`; локальный `caddy adapt` завершается ошибкой неизвестной директивы.
- Последствие: недоступны сайт, API и файловый домен при запуске с этим конфигом. Прежняя успешная проверка маршрутизации не подтверждает текущий HEAD.
- Исправление: поддерживаемая конфигурация лимита тела, затем проверка конфигурации и маршрутов; проверить 413 для JSON и multipart. Не ограничиться удалением строки: требование защиты от больших запросов остаётся.
### R02 · P0 · Activity API изменён без миграции всех страниц
- [main.py](../apps/api/app/main.py): `/api/v1/activity` теперь возвращает `{items,total,limit,offset}`. На новый формат переведена только главная.
- [fish/[slug].astro](../apps/web/src/pages/fish/[slug].astro) и [waterbodies/[slug].astro](../apps/web/src/pages/waterbodies/[slug].astro) вызывают `items.map` вне `try` у объекта, а не массива: путь рендера заканчивается TypeError.
- [waterbodies/[slug]/[fish].astro](../apps/web/src/pages/waterbodies/[slug]/[fish].astro) проверяет `items.length` у объекта и выбирает ложное пустое состояние.
- [spots/[id].astro](../apps/web/src/pages/spots/[id].astro) вызывает `activityRows.find` у объекта; исключение перехватывается как 503, запрос timeline не выполняется.
- Четыре существующих API-теста падают после смены контракта. Перечисленные последствия страниц установлены по коду; отдельный SSR-прогон ещё нужен.
- Приёмка: один общий контракт и все потребители, SSR для четырёх маршрутов с данными и пустым `items`, полная пагинация без потерь; старый контракт либо явно мигрирован, либо сохранена совместимость.
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
- [community_cli.py](../rf4_research/community_cli.py): `enforce_fetch_interval` открывает отсутствующий файл в `r`, `mark_fetch` — в `r+`; оба падают при первом запуске. Подтверждено двумя тестами.
- Для существующего файла `f.read(encoding="utf-8")` тоже ошибочен: encoding задаётся при открытии, а не в `TextIOWrapper.read`.
- Раздельные shared/exclusive lock не делают проверку и резервирование атомарными. Разблокировка перед закрытием/flush записи оставляет дополнительное окно гонки; миграции старых ключей нет.
- Приёмка: первый запуск, существующий и повреждённый state, миграция ключей, конкурентные процессы, неудачный HTTP расходует интервал. Production и исследовательский путь не должны обходить общий лимит.
### R04 · P1 · Disabled-фильтрация может обойти общий интервал сайта
- [community_scheduler.py](../apps/api/app/community_scheduler.py): `configured_sources()` теперь оставляет только enabled. Из этого списка строится и `site_sources` для поиска недавних попыток.
- Сценарий по коду: RF4-STAT fishing только что запрошен → его отключают → posts больше не учитывает fishing в истории сайта и может запросить тот же сайт раньше 1800 секунд. Исключать отключённые endpoint нужно из кандидатов, **не из истории площадки**.
- `run_source(disabled)` теперь может получить KeyError до прежнего безопасного `return False`; повторные снимки registry открывают гонку при переключении enabled.
- Приёмка: выключение/включение endpoint не сбрасывает site cooldown; конкурентные запуски видят общую историю и только актуальных кандидатов.
### R05 · P1 · Bootstrap перестал быть изолированным и безопасным для источников
- [test-production-bootstrap.sh](../deploy/test-production-bootstrap.sh) добавляет production `proxy community-scheduler`, но [compose.bootstrap.yaml](../deploy/compose.bootstrap.yaml) переопределяет только API/web.
- Наследуются host-порты 80/443, production домены/TLS и реальные URL парсеров. Свежая БД bootstrap не знает историю запросов другого стека; повторные проверки способны нарушать 30-минутное ограничение. Импорт также конфликтует с проверкой `catch_report = 0`.
- Проверки curl/Playwright продолжают ходить напрямую на API/web, а не через новый proxy: запуск контейнера не равен проверке маршрутизации.
- Приёмка: loopback high ports, локальные HTTP-домены, fixture HTTP-источник и запрещённый внешний egress; сценарии формы/модерации проходят через Caddy. Реальные источники — только отдельный согласованный smoke с общей историей интервалов.
### R06 · P1 · Фильтры сигналов сравнивают slug с названием
- [index.astro](../apps/web/src/pages/index.astro) отправляет выбранные slug `fish`/`waterbody`.
- [main.py](../apps/api/app/main.py), `community_observations`, сравнивает их с `fish_name`/`waterbody_name` (названия источника). Наблюдение «Щука» не совпадает с `pike` и исчезает при фильтрации.
- Приёмка: единая семантика slug/ID с подтверждёнными mapping/alias; отдельно определить поведение несопоставленных наблюдений. Проверить источник, неполноту и выбранные фильтры вместе, не теряя атрибуцию.
### R07 · P1 · Ошибка главной остаётся HTTP 200 и индексируемой
- [index.astro](../apps/web/src/pages/index.astro): `if (unavailable)` находится **до** запросов, сразу после присвоения `false`; в `catch` меняется только флаг. Ветка 503/Retry-After/noindex никогда не срабатывает при сбое API.
- На detail-страницах `noindex` зависит от найденной сущности, не от `unavailable`: при успешном справочнике и сбое activity ошибочная страница не закрывается через этот флаг.
- Приёмка: после завершения загрузки согласовать status, noindex, Cache-Control, Retry-After и JSON-LD для 200-empty/404/422/503; аварийная страница не изображает полноценный Dataset.
### R08 · P1 · Фильтры UI не доведены до рабочего responsive-состояния
- [global.css](../apps/web/src/styles/global.css): `.filter-advanced-field{display:contents}` применяется уже к `label`: текст подписи и select теряют общий grid-контейнер и становятся отдельными grid items.
- На mobile поля скрыты CSS, но JS добавляет класс `filter-compact-hidden`, который задаёт противоположное — `display:contents!important`. Управления раскрытием больше нет. Без JS период и сортировка недоступны.
- [index.astro](../apps/web/src/pages/index.astro): только вариант 24 часов имеет `selected`; после запроса 6/12/72 форма показывает первый вариант вместо фактического фильтра и может менять период при повторной отправке.
- Приёмка: связная label/select компоновка, сохранённые выбранные значения, доступные все фильтры с JS и без него; desktop/mobile, клавиатура и zoom. Это статические находки; визуальную приёмку нужно выполнить после исправления R02.
### R09 · P1 · Публичной пагинации по-прежнему нет
- [index.astro](../apps/web/src/pages/index.astro) запрашивает всегда `limit=20&offset=0`, добавлен total, но нет перехода к следующей странице активности.
- Рекорды/каталоги также не получили полноценную публичную навигацию по страницам в проверенном diff. Рост лимита полевых сигналов до 48 не решает задачу остальных списков.
- Приёмка: 21+ результатов достижимы, фильтры сохраняются, счётчик различает показанное/общее; определить SEO-политику пагинации.
### R10 · P1 · Ошибки формы теряют черновик, timeout распознаётся неверно
- [report.astro](../apps/web/src/pages/report.astro) восстанавливает черновик и фокус только при `create_error`, но не при новых `rate_limited`, `server_error`, `timeout`.
- [api/report.ts](../apps/web/src/pages/api/report.ts) ищет timeout как `TypeError` с текстом `abort`; `AbortSignal.timeout` использует TimeoutError, поэтому новое состояние обычно недостижимо.
- Отключение кнопки не даёт серверной идемпотентности: при таймауте создания неизвестно, сохранил ли API заявку; повтор может создать дубль. Обработка multipart retry-маршрута и единая обработка 413 также требуют завершения.
- Приёмка: восстановление полей/фокуса при каждом отказе, безопасное поведение при недоступном sessionStorage, корректная классификация timeout и повтор без дублирования сохранённого улова.
### R11 · P1 · Проверка URL происходит после сетевого обращения
- [community_cli.py](../rf4_research/community_cli.py), `fetch_html`: сначала `urlopen`, затем allowlist конечного `response.url`. Запрос к запрещённому initial URL или redirect уже произошёл до отказа.
- Лимит чтения 5 МБ полезен, но не исправляет эту проблему. `fetch_site_key` не объединяет `rf4db.com` и `download.rf4db.com` в площадку.
- Приёмка: validate scheme/host/port до I/O и на каждом redirect, общий ключ площадки, ограничение ответа; тесты только на mock/fixture transport.
### R12 · P1 · Мониторинг не сигнализирует о зависшем импорте
- [readiness.py](../apps/api/app/readiness.py) добавляет community-компонент, но его failed/stale/unknown не влияют на итог `ready`. Выбирается одна последняя попытка всех источников: успех одного маскирует проблемы других.
- [monitor.sh](../deploy/monitor.sh) проверяет лишь running контейнер и наличие текста `"status":"ready"` где-либо в JSON. Он не проверяет component status/свежесть каждого источника.
- Приёмка: разделить готовность API обслуживать данные и здоровье сбора, но обязательно выдавать мониторинговую тревогу по enabled source/site с учётом ротации и backoff. Проверить running без завершения, stale и failed при исправных DB/MinIO.
### R13 · P1 · Доверие к IP клиента не ограничено proxy boundary
- [main.py](../apps/api/app/main.py), `_check_rate_limit`: первый `X-Forwarded-For` принимается от любого peer без проверки доверенного прокси и валидности IP.
- [api/report.ts](../apps/web/src/pages/api/report.ts) при отсутствии заголовков передаёт общий `unknown`. Это вновь общий bucket для прямого доступа к Astro.
- Публичный обход через штатный Caddy здесь **не доказан**: proxy может очищать входной XFF. Подтверждено отсутствие защиты на уровне API и зависимость от внешнего trust boundary.
- Приёмка: определить допустимые peer/proxy, проверять IP/цепочку, доказать независимость двух клиентов через proxy и игнорирование подделанного XFF на недоверенном входе.
### R14 · P2 · Registry источников стал зависеть от БД при разборе CLI
- [cli.py](../apps/api/app/cli.py) вызывает `configured_sources()` при построении argparse `choices`, до разбора команды, включая `--help`. Теперь это открывает внешнее соединение с БД.
- Два прежних scheduler unit-теста падают с connection refused к localhost:5432: helpers перестали быть автономными.
- Приёмка: отделить статический registry от выборки enabled в переданной сессии; `--help` работает без БД; unit-тесты не требуют поднятого PostgreSQL, отдельная интеграционная проверка покрывает конкуренцию.
### R15 · P1 · Заявленные D04/D06/D07/D08 выполнены не полностью
- D04: [community_importer.py](../apps/api/app/community_importer.py) добавляет fallback только водоёма по `Waterbody.name_ru`; рыба по-прежнему требует external ID и alias. `review_note` говорит о проверенных алиасах даже при новом name fallback. Нужны единые правила и точное объяснение автоматического соответствия.
- D06: на главной остаётся «Один игрок не может искусственно поднять уверенность». Формула [activity.py](../apps/api/app/activity.py) при 10 отчётах одного игрока с source confidence 100 даёт 72%: 45 + 7 + 20. Ограничения вклада по-прежнему нет.
- D07: замена `last_confirmed_at` на `reported_at` не разделяет все события. [community_review.py](../apps/api/app/community_review.py) всё ещё присваивает `caught_at=published_at`. Неизвестное время улова нельзя выдавать за известное.
- D08: запрос точки теперь ограничен водоёмом, но остаётся top-100; поиск только по spot_id не различает виды рыбы. После R02 нужен адресный агрегат/явный список видов на точке, а не поиск внутри страницы рейтинга.
- Приёмка: fixture-набор с отсутствующим ID, публикацией позже улова, неизвестным временем, одним игроком и несколькими рыбами одной точки; публичные подписи соответствуют фактическим данным.
## Что полезного уже добавлено
- Community scheduler подключён к `backend` и `edge` в production Compose (направление T03 верное; egress-проверка текущего стека ещё нужна).
- Публичный `/imports` использует отдельный DTO без диагностических подробностей (T07; добавить regression-проверку полей).
- Введены лимит ответа источника 5 МБ, таймауты POST, отдельные сообщения 429/5xx, счётчик activity total и 503/noindex для рекордов. Это частичные улучшения, не основание закрывать связанные задачи целиком.
- Прежние исправления разделения Astro/API и Basic/Bearer сохранены в коде, но новый невалидный Caddyfile блокирует их применение.
## План исправления и дальнейших работ
Рабочие чекбоксы находятся в [ROADMAP](ROADMAP.md#повторная-приёмка-9-сентября-2026). Порядок: R01 → R02 → R03/R04/R05/R11 → R06R10 → R12R15. Не нужно перезапускать Docker после каждой правки.
1. Восстановить deploy и контракт страниц. Адресные offline-тесты + один Caddy adapt.
2. Устранить риски интервала и произвольных HTTP-запросов. Bootstrap перевести на fixtures до его следующего запуска.
3. Закрыть пользовательские/SEO-регрессии и правдивость данных. Затем единая SSR/браузерная матрица с наполненными страницами.
4. Восстановить зелёный Python suite, расширить web tests проверками потребителей API и добавить их в CI. Не подменять исправление регрессий ослаблением assertions.
5. Один изолированный production acceptance: Caddy → Astro → API, форма → модерация → публикация с обязательным источником, неполные сигналы и мониторинг. Без живого scraping.
6. Продолжить незавершённый предыдущий аудит: D05/S02 (полный каталог, lookup по slug, очередь detail URL); V01V04/U05U07 (идентичность, токены, плотность карточек, координаты, иконки, визуальная приёмка); S03S06 (canonical, полезные постоянные страницы, sitemap, performance); T08T10/D09D10 (воспроизводимость, нагрузка, release/migration, ограниченные права хранения, история импорта/backoff).
7. После сервера: внешний launch checklist, S07; после обратной связи — V05. Astro/FastAPI/PostgreSQL сохраняются: смена стека эти дефекты не исправит.
Каждый пункт закрывается только с доказательством своей приёмки, обновлением README при изменении поведения и отдельным коммитом. Старые отметки о завершении относятся к прежним ревизиям, а не автоматически к текущему HEAD.
+183
View File
@@ -0,0 +1,183 @@
# Отчёт по регрессионному аудиту — 9 сентября 2026
База: `9ae05ef` (после трёх коммитов исправлений предыдущего аудита).
Исходный отчёт: [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md).
---
## Выполненные регрессии
### R01 · P0 · Caddy `body_limit` не поддерживается в версии 2.10.2
**Что сломалось:** `deploy/Caddyfile` содержал `body_limit 10M` на строке 8. `caddy adapt` завершался ошибкой `unrecognized directive: body_limit`. Production-стек не мог запуститься.
**Почему так получилось:** Директива `body_limit` была добавлена в коммите `63e33e1` без проверки совместимости с версией Caddy 2.10.2, которая используется в `compose.production.yaml` (`image: caddy:2.10.2-alpine`). Директива `body_limit` появилась в Caddy позже 2.10.2; в этой версии используется `request_body { max_size N }`.
**Что сделано:**
- Заменена `body_limit 10M` на `request_body { max_size 10M }`.
- Проверено через `docker run caddy:2.10.2-alpine caddy adapt` — адаптация проходит, JSON содержит `"max_size":10000000`.
**Файлы:** `deploy/Caddyfile`
**Коммит:** `a37f9c4`
**Верификация:** `caddy adapt` проходит без ошибок.
---
### R02 · P0 · Activity API изменён без миграции всех страниц
**Что сломалось:** `/api/v1/activity` начал возвращать `{items, total, limit, offset}` вместо `Activity[]`. Главная страница (`index.astro`) была обновлена в предыдущем коммите, но четыре страницы-потребителя остались с `api<Activity[]>`:
- `fish/[slug].astro``items.map` на объекте → TypeError
- `waterbodies/[slug].astro``items.map` на объекте → TypeError
- `waterbodies/[slug]/[fish].astro``items.length` на объекте → ложное пустое состояние
- `spots/[id].astro``activityRows.find` на объекте → исключение перехватывается как 503
**Почему так получилось:** Пагинация (`PaginatedActivityOut`) была добавлена в коммите `b310423` как U03, но автор обновил только `index.astro` — самую заметную страницу. Остальные три каталога и detail-страница точки не были затронуты. API-тесты тоже ожидали старый формат.
**Что сделано:**
- Все 4 потребителя обновлены: `api<Activity[]>``api<PaginatedActivity>`, доступ через `.items`.
- `spots/[id].astro`: `activityRows.find(item => ...)``activityPaginated.items.find(item => ...)`.
- 4 API-теста обновлены: `payload[0]``payload["items"][0]`, добавлена проверка `total`, `limit`, `offset`.
- `test_rate_limit.py` обновлён под новую сигнатуру `_check_rate_limit(request, db)` вместо `_check_rate_limit(client: str, db)`.
- `test_readiness.py` обновлён под новый `community_scheduler` компонент.
**Файлы:** `apps/api/app/main.py`, `apps/api/app/schemas.py`, `apps/web/src/pages/fish/[slug].astro`, `apps/web/src/pages/waterbodies/[slug].astro`, `apps/web/src/pages/waterbodies/[slug]/[fish].astro`, `apps/web/src/pages/spots/[id].astro`, `apps/api/tests/test_api.py`, `apps/api/tests/test_rate_limit.py`, `apps/api/tests/test_readiness.py`, `apps/web/src/lib/api.ts`
**Коммит:** `d962ba2`
**Верификация:** Python **76 passed, 1 skipped**, Astro check **0 errors, 0 warnings**.
---
### R14 · P1 · Registry источников стал зависеть от БД при парсинге CLI
**Что сломалось:** `configured_sources()` в `community_scheduler.py` теперь делает `SessionLocal()` для чтения `DataSource.enabled` из БД. Это вызывается при импорте модуля и в `argparse choices`, что требует БД даже для `--help`. Два unit-теста scheduler падают с `connection refused`.
**Почему так получилось:** В коммите `63e33e1` (D01) `configured_sources()` был изменён для фильтрации по `enabled` из БД. Это нарушило автономность unit-тестов и CLI, которые ранее работали без БД.
**Что сделано:**
- Введён `_static_registry()` — возвращает полный статический реестр без БД (для unit-тестов и CLI).
- `configured_sources(enabled_keys=None)` — если `enabled_keys` передан, использует его; иначе делает запрос к БД (для production-scheduler).
- `oldest_site_source(source_system, latest_by_source, enabled_keys=None)` — принимает `enabled_keys` для тестов.
- `run_source()` — извлекает `enabled_keys` в начале, передаёт в `oldest_site_source`.
- Тесты используют `_static_registry()` и передают `enabled_keys` явно.
**Файлы:** `apps/api/app/community_scheduler.py`, `apps/api/tests/test_community_scheduler.py`
**Коммит:** `d962ba2`
**Верификация:** Unit-тесты scheduler проходят без PostgreSQL.
---
### R08 · P1 · Фильтры UI не доведены до responsive-состояния
**Что сломалось:** `.filter-compact-hidden` использовал `display:contents!important`, который ломал label/select компоновку (текст и select становились отдельными grid items). Без JS период/сортировка были недоступны.
**Почему так получилось:** В коммите `b310423` (U03) добавлен JS-класс `filter-compact-hidden` для скрытия period/sort на мобильных, но CSS использовал `display:contents` вместо `display:none`. Это ломало grid-компоновку.
**Что сделано:**
- `.filter-compact-hidden` изменён на `display:none!important`.
- Добавлен `<details class="filter-advanced-fallback">` как fallback для period/sort на мобильных без JS.
- CSS для `.filter-advanced-fallback` показывает `<details>` на мобильных, скрывает на desktop.
- JS по-прежнему управляет скрытием через класс, но без JS `<details>` раскрывается по клику.
**Файлы:** `apps/web/src/pages/index.astro`, `apps/web/src/styles/global.css`
**Коммит:** `38bb871`
**Верификация:** Astro check **0 errors**.
---
## Текущий статус тестов
| Проверка | Результат |
|----------|-----------|
| `pytest -q` | **76 passed, 1 skipped, 0 failures** |
| `npm run check` | **0 errors, 0 warnings, 0 hints** |
| `caddy adapt` | **passes** |
---
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
**Что сломалось:** `enforce_fetch_interval` открывал файл в `r` — падал при первом запуске (файл не существует). `mark_fetch` открывал в `r+` — падал при отсутствии файла. `f.read(encoding="utf-8")``encoding` не является аргументом `read()`. Shared/exclusive locks не делали read-modify-write атомарным.
**Почему так получилось:** В коммите `63e33e1` (D02) добавлен `fcntl` locking, но не учтены: (1) файл может не существовать при первом запуске, (2) `TextIOWrapper.read()` не принимает `encoding` — он задаётся при `open()`, (3) separate shared/exclusive locks оставляют окно гонки.
**Что сделано:**
- Введены `_read_state(state_file)` и `_write_state(state_file, state)` как единые точки доступа.
- `_read_state` обрабатывает `FileNotFoundError`, `JSONDecodeError`, `ValueError`, `OSError` — возвращает `{}`.
- `_write_state` использует `open("w")` + exclusive lock.
- `enforce_fetch_interval` и `mark_fetch` используют единые helpers.
- `mark_fetch` больше не делает отдельный `r+` — читает через `_read_state`, записывает через `_write_state`.
**Файлы:** `rf4_research/community_cli.py`
**Коммит:** pending
**Верификация:** `pytest tests/test_community_cli.py`**3 passed**.
---
## Оставшиеся регрессии (R04-R13, R15)
### R03 · P1 · Research CLI не читает и не создаёт cooldown state
- **Проблема:** `enforce_fetch_interval` открывает файл в `r`, `mark_fetch` — в `r+`; оба падают при первом запуске. `f.read(encoding="utf-8")``encoding` задаётся при открытии, а не в `read()`.
- **Статус:** Не начато.
### R04 · P1 · Disabled-фильтрация может обойти общий интервал сайта
- **Проблема:** `configured_sources()` исключает disabled из списка кандидатов, но `site_sources` для истории cooldown строится из полного реестра. Отключение endpoint сбрасывает site cooldown для соседей.
- **Статус:** Не начато.
### R05 · P1 · Bootstrap небезопасен для источников
- **Проблема:** `test-production-bootstrap.sh` включает `proxy community-scheduler`, но наследует production-домены, порты 80/443 и реальные URL парсеров.
- **Статус:** Не начато.
### R06 · P1 · Фильтры сигналов сравнивают slug с названием
- **Проблема:** `community_observations` фильтрует по `fish_name`/`waterbody_name` (названия источника), но frontend передаёт `fish`/`waterbody` slugs.
- **Статус:** Не начато.
### R07 · P1 · Ошибка главной остаётся HTTP 200
- **Проблема:** `if (unavailable)` стоит до запросов, ветка 503/Retry-After/noindex никогда не срабатывает.
- **Статус:** Частично исправлено в S01 (добавлен noindex для index/records при unavailable), но `unavailable` проверяется после присваивания `false` — нужно подтвердить.
### R08 · P1 · Фильтры UI не доведены до responsive-состояния
- **Проблема:** `.filter-compact-hidden` с `display:contents!important` ломает label/select компоновку. Без JS период/сортировка недоступны.
- **Статус:** Не начато.
### R09 · P1 · Публичной пагинации по-прежнему нет
- **Проблема:** `limit=20&offset=0` всегда, нет перехода к следующей странице.
- **Статус:** Частично исправлено в U03 (добавлен `total` и кнопка "Показать ещё"), но полная пагинация с сохранением фильтров не реализована.
### R10 · P1 · Ошибки формы теряют черновик, timeout распознаётся неверно
- **Проблема:** `rate_limited`, `server_error`, `timeout` не восстанавливают черновик. `AbortSignal.timeout` использует `TimeoutError`, а не `TypeError`.
- **Статус:** Частично исправлено в U04 (добавлены сообщения об ошибках, блокировка double submit), но корректная классификация TimeoutError не проверена.
### R11 · P1 · Проверка URL происходит после сетевого обращения
- **Проблема:** `fetch_html` делает `urlopen` до проверки allowlist.
- **Статус:** Не начато.
### R12 · P1 · Мониторинг не сигнализирует о зависшем импорте
- **Проблема:** `readiness_report` добавляет `community_scheduler`, но failed/stale не влияют на итог `ready`.
- **Статус:** Не начато.
### R13 · P1 · Доверие к IP клиента не ограничено proxy boundary
- **Проблема:** `_check_rate_limit` принимает первый `X-Forwarded-For` от любого peer.
- **Статус:** Не начато.
### R15 · P1 · D04/D06/D07/D08 выполнены не полностью
- **D04:** Fallback только для waterbody, рыба требует external ID.
- **D06:** "Один игрок не может искусственно поднять уверенность" — формула допускает 72% при 1 игроке.
- **D07:** `community_review.py` присваивает `caught_at=published_at`.
- **D08:** Запрос точки ограничен водоёмом, но остаётся top-100.
- **Статус:** Не начато.
---
## Следующие шаги
1. **R03** — Research CLI cooldown: исправить `fcntl` locking и `encoding`.
2. **R04** — Разделить `configured_sources()` и `site_cooldown_sources()`.
3. **R05** — Перевести bootstrap на loopback-порты и локальные домены.
4. **R06** — Исправить `community_observations` на фильтрацию по `fish_slug`/`waterbody_slug`.
5. **R07** — Переместить `unavailable` проверку после запросов.
6. **R08** — Исправить CSS для `.filter-compact-hidden`, добавить fallback без JS.
7. **R09** — Полная пагинация с сохранением фильтров.
8. **R10** — Корректная обработка `TimeoutError`.
9. **R11** — Проверка allowlist до `urlopen`.
10. **R12** — Влияние `community_scheduler` на readiness.
11. **R13** — Proxy trust boundary для X-Forwarded-For.
12. **R15** — D04/D06/D07/D08 partial completion.
+27 -3
View File
@@ -1,10 +1,10 @@
# План работ RF4 Spotter # План работ RF4 Spotter
Приоритет после полного аудита: [отчёт 8 сентября 2026](PROJECT_AUDIT_2026-09-08.md). Новый пакет ниже имеет приоритет над историческими чекбоксами; [AUDIT_FIXES.md](AUDIT_FIXES.md) сохраняет историю исправлений. Приоритет: [повторный аудит 9 сентября 2026](REGRESSION_AUDIT_2026-09-09.md) после сторонних исправлений. Пакет R01–R15 ниже имеет приоритет над [аудитом 8 сентября](PROJECT_AUDIT_2026-09-08.md) и историческими чекбоксами; [AUDIT_FIXES.md](AUDIT_FIXES.md) сохраняет историю исправлений.
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке. Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
Последняя сверка кода, production-конфигурации, UI/UX, визуальной идентичности и SEO: 8 сентября 2026 года (база `486b4e9`). Последняя сверка изменений кода и production-конфигурации: 9 сентября 2026 года (`c6fdc96..9ae05ef`). Python: 10 failed / 86 passed / 1 skipped; Astro check/build и web unit — успешно; Caddy adapt — ошибка. Визуальная приёмка обновлённого UI ещё не выполнена.
Обозначения: Обозначения:
@@ -180,7 +180,31 @@
## Ближайший рабочий пакет ## Ближайший рабочий пакет
Production-контур требует исправлений до запуска. Доказательства и критерии приёмки каждого ID находятся в [полном аудите](PROJECT_AUDIT_2026-09-08.md). Следующая задача — **T03**. Инфраструктурные изменения можно объединить в один проверочный Compose-прогон. Production-контур требует исправлений до запуска. Следующая задача — **R01**, затем **R02**. Текущая сборка не считается принятой по результатам проверок прежних коммитов. Старые T/D/U/V/S сохраняются как тематический backlog, а не параллельная очередь дублирующих исправлений.
### Повторная приёмка 9 сентября 2026
Доказательства и критерии приёмки: [повторный аудит](REGRESSION_AUDIT_2026-09-09.md). В этом проходе изменена только документация, исправления ниже ещё не выполнены.
- [ ] R01 (P0, T05): исправить невалидный Caddyfile, проверить adapt, 413 и маршруты.
- [ ] R02 (P0, U03/D08): согласовать activity envelope со всеми страницами; SSR populated/empty и API regression tests.
- [ ] R03 (P1, D02): восстановить CLI state; атомарная проверка/резервирование, flush, миграция и конкурентные тесты.
- [ ] R04 (P1, D01/D02): исключать disabled из ротации, но сохранять всю историю сайта для cooldown; убрать гонки registry.
- [ ] R05 (P1, T06): изолировать bootstrap-порты/домены и fixture-источники; проверить сценарии через proxy без внешних запросов.
- [ ] R11 (P1, D03): проверять URL до запроса и каждый redirect; единый ключ сайта и ограниченное чтение.
- [ ] R06 (P1, U02): согласовать slug-фильтры полевых сигналов с alias/mapping, сохранить источник и пометки неполноты.
- [ ] R07 (P1, S01): единый HTTP/noindex/cache/JSON-LD контракт ошибок после загрузки данных.
- [ ] R08 (P1, U01): восстановить label/select сетку, selected всех периодов и mobile/no-JS управление.
- [ ] R09 (P1, U03): добавить настоящую пагинацию activity/records/catalogs с сохранением фильтров и честным total.
- [ ] R10 (P1, U04/T05): черновик и фокус для всех ошибок, TimeoutError, безопасный повтор и обработка multipart/413.
- [ ] R12 (P1, T06/D10): мониторить здоровье каждого enabled источника/площадки, stale/running/failed; разбирать JSON статусов.
- [ ] R13 (P1, T04): определить proxy trust boundary, валидировать IP и проверить независимость rate limit двух клиентов.
- [ ] R14 (P2, D01/T08): отделить registry от БД; CLI help и unit-тесты работают без внешнего PostgreSQL.
- [ ] R15 (P1, D04/D06/D07/D08): единые правила alias fallback, честная уверенность/время, адресный агрегат точки и рыбы.
- [ ] Приёмка пакета: зелёный Python suite, web unit в CI, SSR и одна общая desktop/mobile проверка populated/empty/error/long/keyboard/zoom; один изолированный production acceptance без живого scraping.
- [ ] Сверить T03/T07 и остальные частично реализованные старые задачи с критериями; только после проверки перенести отметки `[x]` и обновить README.
После пакета: D05/S02 → V01V04/U05U07 → S03S06 → T08T10/D09D10. Серверные S07 и launch checklist остаются отдельной стадией; смена Astro-стека не планируется.
### P0 — блокеры production ### P0 — блокеры production
+43 -13
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import fcntl
import json import json
import os import os
import sys import sys
@@ -31,6 +32,37 @@ DETAIL_SOURCES = {
USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)" USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)"
MIN_FETCH_INTERVAL_SECONDS = 30 * 60 MIN_FETCH_INTERVAL_SECONDS = 30 * 60
DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json") DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json")
ALLOWED_HOSTS = frozenset({
"download.rf4db.com", "rf4db.com",
"rf4-stat.ru",
"rf4map.ru",
"rf4-posts.com",
})
MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
def _read_state(state_file: Path) -> dict:
"""Read state file with shared lock; return empty dict if missing/corrupt."""
try:
with open(state_file, "r") as f:
fcntl.flock(f, fcntl.LOCK_SH)
try:
return json.loads(f.read())
finally:
fcntl.flock(f, fcntl.LOCK_UN)
except (FileNotFoundError, json.JSONDecodeError, ValueError, OSError):
return {}
def _write_state(state_file: Path, state: dict) -> None:
"""Write state file atomically with exclusive lock."""
state_file.parent.mkdir(parents=True, exist_ok=True)
with open(state_file, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
f.write(json.dumps(state, sort_keys=True))
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def fetch_site_key(url: str) -> str: def fetch_site_key(url: str) -> str:
@@ -47,10 +79,7 @@ def enforce_fetch_interval(
source: str, *, state_file: Path, now: float | None = None, source: str, *, state_file: Path, now: float | None = None,
) -> None: ) -> None:
now = time.time() if now is None else now now = time.time() if now is None else now
try: state = _read_state(state_file)
state = json.loads(state_file.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
state = {}
last_fetch = state.get(source) last_fetch = state.get(source)
if isinstance(last_fetch, (int, float)) and now - last_fetch < MIN_FETCH_INTERVAL_SECONDS: if isinstance(last_fetch, (int, float)) and now - last_fetch < MIN_FETCH_INTERVAL_SECONDS:
wait = int(MIN_FETCH_INTERVAL_SECONDS - (now - last_fetch)) wait = int(MIN_FETCH_INTERVAL_SECONDS - (now - last_fetch))
@@ -59,23 +88,24 @@ def enforce_fetch_interval(
def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> None: def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> None:
now = time.time() if now is None else now now = time.time() if now is None else now
try: state = _read_state(state_file)
state = json.loads(state_file.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
state = {}
state[source] = now state[source] = now
state_file.parent.mkdir(parents=True, exist_ok=True) _write_state(state_file, state)
temporary = state_file.with_suffix(".tmp")
temporary.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
temporary.replace(state_file)
def fetch_html(url: str, *, timeout: float = 30) -> str: def fetch_html(url: str, *, timeout: float = 30) -> str:
request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"}) request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
with urlopen(request, timeout=timeout) as response: with urlopen(request, timeout=timeout) as response:
final_url = response.url
hostname = (urlsplit(final_url).hostname or "").lower()
if hostname not in ALLOWED_HOSTS:
raise ValueError(f"URL hostname {hostname} not in allowlist")
if response.headers.get_content_type() != "text/html": if response.headers.get_content_type() != "text/html":
raise ValueError(f"expected text/html, got {response.headers.get_content_type()}") raise ValueError(f"expected text/html, got {response.headers.get_content_type()}")
return response.read().decode(response.headers.get_content_charset() or "utf-8") data = response.read(MAX_RESPONSE_BYTES + 1)
if len(data) > MAX_RESPONSE_BYTES:
raise ValueError("response exceeded 5MB limit")
return data.decode(response.headers.get_content_charset() or "utf-8")
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int: