Compare commits

..
135 Commits
Author SHA1 Message Date
ik 59bf85ac23 docs: record admin media search
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:44:02 +07:00
ik b713284bab chore: publish admin media search contract 2026-09-21 20:43:42 +07:00
ik 8089570d56 feat: add media review search field 2026-09-21 20:43:31 +07:00
ik 504a8ce552 feat: add admin media label search 2026-09-21 20:43:31 +07:00
ik a46d0e0eac fix: constrain admin media role choices 2026-09-21 20:41:35 +07:00
ik 6a3c6c54de fix: reject incompatible admin media roles 2026-09-21 20:41:35 +07:00
ik c54cb71720 chore: publish admin media role contract 2026-09-21 20:40:27 +07:00
ik b68826836e docs: record admin media role filtering 2026-09-21 20:40:06 +07:00
ik 00f91dbef8 feat: add admin media role filter 2026-09-21 20:39:52 +07:00
ik ad69f90e43 feat: filter admin media by role 2026-09-21 20:39:49 +07:00
ik 8f03eec76a fix: clear moderation selection on logout 2026-09-21 20:38:42 +07:00
ik 78eaf57189 docs: record bulk action safeguards 2026-09-21 20:38:10 +07:00
ik 191f94b834 fix: harden external bulk actions 2026-09-21 20:37:55 +07:00
ik fe3ca11d8f docs: record queue parity improvements
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:36:14 +07:00
ik 86c399f2da feat: add select-all to moderation queue 2026-09-21 20:35:48 +07:00
ik 2a845e8b8d chore: publish bulk publish API contract 2026-09-21 20:34:22 +07:00
ik 97742d4716 docs: record bulk publish workflow 2026-09-21 20:34:04 +07:00
ik e47db12e4d feat: add bulk publish controls 2026-09-21 20:33:53 +07:00
ik bed156ae94 feat: add bulk external observation publishing 2026-09-21 20:33:50 +07:00
ik 8e84f41924 chore: publish bulk mapping API contract 2026-09-21 20:31:09 +07:00
ik 167f23a99f docs: record bulk mapping workflow 2026-09-21 20:31:00 +07:00
ik ec46655e13 feat: add bulk mapping controls 2026-09-21 20:30:43 +07:00
ik 28a361314a feat: add bulk external observation mapping 2026-09-21 20:30:41 +07:00
ik bc02d28169 docs: record bulk queue workflow 2026-09-21 20:28:32 +07:00
ik 5ffe05206c feat: add select-all for external queue 2026-09-21 20:28:09 +07:00
ik 1dbda03209 fix: make bulk observation rejection atomic 2026-09-21 20:27:45 +07:00
ik 1ac27986cb feat: add bulk external rejection controls 2026-09-21 20:27:17 +07:00
ik 21c0ffbe75 feat: add bulk external observation rejection 2026-09-21 20:25:51 +07:00
ik e74929e7f7 feat: add bulk moderation decisions 2026-09-21 20:25:17 +07:00
ik 2fc4b88a80 feat: expand admin import history 2026-09-21 20:23:32 +07:00
ik 55e5810e65 feat: search official records
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:21:12 +07:00
ik f711b9ac0b feat: restore admin import cooldown
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:20:22 +07:00
ik 4f1381cec8 feat: paginate and label media library 2026-09-21 20:19:22 +07:00
ik c38097767f feat: link tackle items to related rigs 2026-09-21 20:18:23 +07:00
ik 26ad9b5194 feat: guard admin import cooldown
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:16:43 +07:00
ik 2f3f6acd78 feat: add activity evidence summaries 2026-09-21 20:15:17 +07:00
ik 0fed0f1bad docs: record media search progress
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:13:08 +07:00
ik 4100691268 feat: search media library 2026-09-21 20:12:51 +07:00
ik 558e284745 docs: record pagination recovery
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:11:28 +07:00
ik 3246350319 fix: clamp stale pair pagination 2026-09-21 20:11:10 +07:00
ik 9dfad82dd0 fix: clamp stale waterbody pagination 2026-09-21 20:10:49 +07:00
ik 060aacff92 fix: clamp stale fish detail pagination 2026-09-21 20:10:26 +07:00
ik 9c49a0850d docs: record catalog search progress 2026-09-21 20:09:29 +07:00
ik 4c4849ad2c feat: search waterbody catalog 2026-09-21 20:09:11 +07:00
ik 1c4065a26c feat: search fish catalog 2026-09-21 20:08:45 +07:00
ik 7350880736 docs: record detail pagination progress 2026-09-21 20:08:04 +07:00
ik ebc4b0e65c feat: paginate fish waterbody detail 2026-09-21 20:07:49 +07:00
ik 9fc9c820c4 feat: paginate waterbody activity detail 2026-09-21 20:07:06 +07:00
ik f3a9f34ff3 feat: paginate fish activity detail 2026-09-21 20:06:36 +07:00
ik c5c356f948 feat: expose confirmed fishing approach 2026-09-21 20:04:40 +07:00
ik f3cf706ede feat: add activity period filters to detail pages 2026-09-21 20:03:08 +07:00
ik 34a5224e78 feat: filter tackle catalog by unlock level
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 20:00:57 +07:00
ik be59d76791 feat: harden fishing plan sharing 2026-09-21 19:59:13 +07:00
ik bf0f16642a feat: show reviewed media on tackle details 2026-09-21 19:57:11 +07:00
ik 524dad42ec feat: separate waterbody media roles 2026-09-21 19:55:50 +07:00
ik 206343895e chore: call rigs assemblies in UI
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 19:54:06 +07:00
ik f0b396b598 feat: search rig catalog by name 2026-09-21 19:51:42 +07:00
ik 4d6ec85173 feat: search tackle catalog by name 2026-09-21 19:50:44 +07:00
ik 6fa669b4ef feat: filter tackle analytics by role 2026-09-21 19:48:49 +07:00
ik b7d69619fa feat: filter public media by role 2026-09-21 19:45:16 +07:00
ik b4585cee7e feat: add discoverable rig catalog 2026-09-21 19:43:17 +07:00
ik 5c1a7ffa01 feat: add honest activity map mode 2026-09-21 19:40:35 +07:00
ik b9aecb75f5 feat: link tackle analytics to canonical gear 2026-09-21 19:37:01 +07:00
ik 3fa97047f2 feat: add public rig detail pages 2026-09-21 19:17:36 +07:00
ik cf8e211176 feat: disclose tackle components in catches 2026-09-21 19:14:07 +07:00
ik 7688df8a5a feat: disclose coordinate provenance 2026-09-21 19:08:49 +07:00
ik b91380b5c5 test: align visual capture matrix 2026-09-21 19:03:35 +07:00
ik ed9ec4bf2b docs: refresh local performance baseline 2026-09-21 18:57:53 +07:00
ik 5d9ffa2e59 feat: label reviewed media roles in admin 2026-09-21 18:53:19 +07:00
ik cd0f6fa5a2 test: cover visual error-state matrix
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 18:49:24 +07:00
ik 4972b11fb0 fix: persist normalized fishing plans 2026-09-21 18:47:08 +07:00
ik 0b830a9978 fix: reject credentialed admin URLs 2026-09-21 18:39:39 +07:00
ik a701397e41 fix: standardize unavailable page responses 2026-09-21 18:34:23 +07:00
ik 11e54a7842 fix: sanitize public provenance links 2026-09-21 18:26:38 +07:00
ik f3eb54083f fix: validate saved plan confidence 2026-09-21 18:23:19 +07:00
ik b0421c11f5 test: cover tackle analytics filters 2026-09-21 18:20:04 +07:00
ik 99ca887f77 fix: clarify empty fishing plan shares 2026-09-21 18:17:36 +07:00
ik d32a1973b2 fix: deduplicate shared fishing plans 2026-09-21 18:15:36 +07:00
ik 2d56a67461 fix: harden saved plan normalization 2026-09-21 18:13:51 +07:00
ik 675cd8ba4e fix: unify coordinate precision labels 2026-09-21 18:12:10 +07:00
ik 31b720f7b3 fix: filter public media roles by entity 2026-09-21 18:09:11 +07:00
ik 4f847db38a test: centralize media role labels 2026-09-21 18:06:09 +07:00
ik d32796277c test: bound tackle freshness score
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 18:04:13 +07:00
ik e4e10bb4e9 feat: add freshness decay to tackle analytics 2026-09-21 18:02:23 +07:00
ik 91019cbf3a docs: clarify browser acceptance evidence 2026-09-21 17:56:14 +07:00
ik de2470010c test: centralize evidence passport states 2026-09-21 17:54:40 +07:00
ik 2729e7a035 test: cover theme persistence and reduced motion
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 17:48:17 +07:00
ik 4052350632 test: isolate unavailable-state regression 2026-09-21 08:20:17 +07:00
ik 852e68d9a5 feat: show ordered tackle components 2026-09-21 08:13:37 +07:00
ik c5f9aa85a0 test: cover unavailable public states 2026-09-21 08:12:04 +07:00
ik c84ec1eced fix: extend accessibility coverage 2026-09-21 08:10:40 +07:00
ik 87321d8384 feat: add tackle combination analytics
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 08:08:23 +07:00
ik 8d7538d423 feat: add public tackle detail route 2026-09-21 08:02:31 +07:00
ik 86515a9da1 feat: expose reviewed media roles 2026-09-21 08:00:15 +07:00
ik 81af8a02b6 test: cover coordinate precision states 2026-09-21 07:56:20 +07:00
ik a8c01f01e9 test: enforce first-answer evidence contract 2026-09-21 07:55:09 +07:00
ik f0b63df803 test: extend visual matrix to core journeys
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 07:53:23 +07:00
ik 94f9e408fb docs: record visual reference review
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
2026-09-21 07:49:14 +07:00
ik 9c521514fc sync 2026-09-21 07:45:00 +07:00
ik b1a2eb5318 test: cover key mobile route contracts 2026-09-21 07:44:28 +07:00
ik eeef4bb536 feat: show catch method on spot detail 2026-09-21 07:42:43 +07:00
ik 10d43013f7 test: cover first useful answer journey 2026-09-21 07:39:28 +07:00
ik 2ed28266df fix: clarify blocked source status 2026-09-21 07:37:32 +07:00
ik 3b4557b549 feat: add method and risk to fishing plans 2026-09-21 07:34:32 +07:00
ik 36d704d854 feat: expose explicit source conflicts 2026-09-21 07:31:17 +07:00
ik bbed832eb8 feat: distinguish insufficient evidence state 2026-09-20 20:52:09 +07:00
ik 6ce9784d0a test: harden saved plan mobile UX 2026-09-20 20:44:58 +07:00
ik 92bb878ced feat: focus home on top spot results 2026-09-20 20:38:17 +07:00
ik 3260f91495 feat: clarify spot card call to action
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 20:34:42 +07:00
ik 346ecd2dbe refactor: prioritize activity card decisions 2026-09-20 20:31:56 +07:00
ik 196185dc97 feat: show evidence analysis periods 2026-09-20 20:29:41 +07:00
ik 7a8e49dde3 fix: qualify low-sample tackle signals
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 20:26:57 +07:00
ik 5a8174b11e feat: label stale evidence data 2026-09-20 20:21:39 +07:00
ik 1dfe1e8ba4 docs: define ux contract and scorecard 2026-09-20 20:18:29 +07:00
ik 9569f4768a feat: share local fishing plans
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 20:17:08 +07:00
ik 5335255c5c feat: add local fishing plan page 2026-09-20 20:12:20 +07:00
ik 3f0a02a9b9 feat: save local fishing plans
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 20:05:50 +07:00
ik e31fbe996d feat: standardize catalog evidence 2026-09-20 20:03:34 +07:00
ik 3a8d3565a1 feat: extend evidence passport to spots 2026-09-20 19:58:39 +07:00
ik 7efdb1a16a feat: clarify home query context 2026-09-20 19:55:33 +07:00
ik d1a5ad1b22 feat: add activity evidence card
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 19:50:19 +07:00
ik 0ac9830c84 docs: add competitive ux roadmap 2026-09-20 19:46:44 +07:00
ik 4bcf301289 test: add media acceptance coverage 2026-09-20 19:42:46 +07:00
ik 72ae157ec3 fix: improve domain accessibility contrast
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 19:25:18 +07:00
ik 25514d9377 test: cover waterbody error states 2026-09-20 19:18:51 +07:00
ik f02cb247a3 feat: add offline waterbody source crosswalk 2026-09-20 19:16:46 +07:00
ik d541443a1a test: cover tackle browser acceptance 2026-09-20 18:42:33 +07:00
ik 46251c635f test: add tackle query acceptance gate
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 18:36:37 +07:00
ik a547d09fcd feat: extend media roles and acceptance coverage
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 18:30:16 +07:00
ik e94d247096 feat: add tackle catalog and recommendation analytics 2026-09-20 18:26:53 +07:00
ik 4f0c2d23de feat: preserve gear components through catch imports
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-20 18:10:41 +07:00
ik 4e9895fbf4 feat: add gear provenance models and browser fetcher 2026-09-20 15:50:43 +07:00
ik b0edae98c6 feat: continue roadmap acceptance work 2026-09-20 15:17:21 +07:00
ik d438c40542 sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-17 07:35:23 +07:00
ik 722c88d436 sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-17 07:35:04 +07:00
154 changed files with 9105 additions and 233 deletions
@@ -0,0 +1,51 @@
# План работы RF4 Spotter — Регрессионный аудит
На основе: [REGRESSION_AUDIT_2026-09-09.md](../docs/REGRESSION_AUDIT_2026-09-09.md)
Дата: 2026-09-09
## Выполнено
### R01 ✅ — Caddy body_limit → request_body max_size
- **Файл**: `deploy/Caddyfile`
- **Проблема**: `body_limit 10M` не поддерживается в Caddy 2.10.2
- **Решение**: `request_body { max_size 10M }`
- **Верификация**: `caddy adapt` проходит без ошибок
### R02 ✅ — Activity API contract regression
- **API**: `main.py``/api/v1/activity` возвращает `PaginatedActivityOut`
- **Frontend**: все 4 потребителя обновлены:
- `index.astro` ✅ (из предыдущего коммита)
- `fish/[slug].astro`
- `waterbodies/[slug].astro`
- `waterbodies/[slug]/[fish].astro`
- `spots/[id].astro`
- **Тесты**: 4 теста обновлены под новый контракт
- **Результат**: 76 passed, 1 skipped, 0 failures
### R14 ✅ — Registry источников больше не зависит от БД при парсинге CLI
- **Файл**: `community_scheduler.py`
- **Решение**: `_static_registry()` — без БД; `configured_sources(enabled_keys)` — с опциональной БД
- **Тесты**: scheduler unit-тесты проходят без PostgreSQL
## Итоги тестов
- **Python**: 76 passed, 1 skipped ✅
- **Astro check**: 0 errors, 0 warnings, 0 hints ✅
- **Caddy adapt**: passes ✅
## Коммиты
1. `a37f9c4` — R01 Caddy body_limit → request_body
2. `d962ba2` — R02 activity API contract + R14 static registry
## Осталось из регрессий (R03-R15)
- R03: Research CLI cooldown broken (fcntl `r`/`r+` + encoding)
- R04: Disabled-фильтрация обходит site cooldown
- R05: Bootstrap небезопасен для источников
- R06: Фильтры сигналов сравнивают slug с названием
- R07: Ошибка главной остаётся HTTP 200
- R08: Фильтры UI не доведены до responsive-состояния
- R09: Пагинация (частично исправлено в U03)
- R10: Ошибки формы теряют черновик
- R11: Проверка URL после сетевого обращения
- R12: Мониторинг не сигнализирует о зависшем импорте
- R13: Доверие к IP клиента не ограничено proxy
- R15: D04/D06/D07/D08 выполнены не полностью
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Environment-dependent path to Maven home directory
/mavenHomeManager.xml
+266
View File
@@ -0,0 +1,266 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GigaCodeAgentSettings">
<option name="autoApproveEdits" value="true" />
<option name="autoApprovedCommands">
<list>
<Execute>
<option name="pattern" value="mkdir -p /home/ik/git/rf4-help/.gigacode/plans" />
</Execute>
<Execute>
<option name="pattern" value="cd *" />
</Execute>
<Execute>
<option name="pattern" value="ls *" />
</Execute>
<Execute>
<option name="pattern" value="pip install *" />
</Execute>
<Execute>
<option name="pattern" value="tail *" />
</Execute>
<Execute>
<option name="pattern" value="python pytest *" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;import ast&#10;import sys&#10;&#10;files = [&#10; 'apps/api/app/main.py',&#10; 'apps/api/app/readiness.py',&#10; 'apps/api/app/schemas.py',&#10; 'apps/api/app/activity.py',&#10; 'apps/api/app/community_scheduler.py',&#10; 'apps/api/app/community_importer.py',&#10; 'rf4_research/community_cli.py',&#10;]&#10;&#10;for f in files:&#10; try:&#10; with open(f) as fh:&#10; ast.parse(fh.read())&#10; print(f'OK: {f}')&#10; except SyntaxError as e:&#10; print(f'ERROR: {f}: {e}')&#10; sys.exit(1)&#10;&#10;print('All Python files syntax OK')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="npm run *" />
</Execute>
<Execute>
<option name="pattern" value="head *" />
</Execute>
<Execute>
<option name="pattern" value="npm install *" />
</Execute>
<Execute>
<option name="pattern" value="git add *" />
</Execute>
<Execute>
<option name="pattern" value="git reset *" />
</Execute>
<Execute>
<option name="pattern" value="git diff *" />
</Execute>
<Execute>
<option name="pattern" value="git log *" />
</Execute>
<Execute>
<option name="pattern" value="git commit *" />
</Execute>
<Execute>
<option name="pattern" value="git status" />
</Execute>
<Execute>
<option name="pattern" value="echo *" />
</Execute>
<Execute>
<option name="pattern" value="printf 'localhost {\n body_limit 10M\n}\n' &gt; /tmp/test-caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="cp /home/ik/git/rf4-help/deploy/Caddyfile /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n body_limit 10M\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="docker run *" />
</Execute>
<Execute>
<option name="pattern" value="sed 's/email {$ACME_EMAIL}/email test@test.com/' /home/ik/git/rf4-help/deploy/Caddyfile &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="grep *" />
</Execute>
<Execute>
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n handle {\n respond \&quot;ok\&quot;\n }\n}\n' &gt; /tmp/Caddyfile" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="node -e &quot;fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e =&gt; console.log(e.constructor.name, e.message));&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="wc *" />
</Execute>
<Execute>
<option name="pattern" value="alembic revision *" />
</Execute>
<Execute>
<option name="pattern" value="python -m app.cli --help 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="python -m rf4_research.community_cli --help 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="alembic heads *" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;&#10;from urllib.request import Request, urlopen&#10;from urllib.error import HTTPError&#10;# Test with a URL that redirects&#10;try:&#10; req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'})&#10; resp = urlopen(req, timeout=5)&#10; print(f'Final URL: {resp.url}')&#10; print(f'Response URL: {resp.url}')&#10;except Exception as e:&#10; print(f'Error: {type(e).__name__}: {e}')&#10;&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="pwd" />
</Execute>
<Execute>
<option name="pattern" value="python3" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;from rf4_research import community_cli; print('Syntax OK')&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/append_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 pytest *" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_redirect_test.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="git show *" />
</Execute>
<Execute>
<option name="pattern" value="git branch *" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_double_reservation_bug.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a02_double_reservation.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_debug.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_final.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/test_a02_code.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_pagination.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_monitoring.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="*" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_tests_v2.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_filters.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a05_sessionstorage.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a06_tests.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_review_note.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_errorpage.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_index.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a11_pip_audit.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a04_pagination_final.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_index_unavailable.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_spots.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a08_records.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_vars.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a01_test.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_auto_publish.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a07_review_note_auto.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 /tmp/fix_a09_cli.py" />
</Execute>
<Execute>
<option name="pattern" value="python3 -c &quot;from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()&quot; 2&gt;&amp;1" />
</Execute>
<Execute>
<option name="pattern" value="find *" />
</Execute>
<Execute>
<option name="pattern" value="git push *" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; # Simulate first request&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; # Check before storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; # Store&#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; # Check after storing&#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, select&#10;from sqlalchemy.orm import Session&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;import hashlib, hmac&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;with Session(engine) as db:&#10; key = 'idem-test-key-001'&#10; key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest()&#10; print(f'Key hash: {key_hash}')&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found before: {existing}')&#10; &#10; from datetime import datetime, timezone&#10; db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc)))&#10; db.commit()&#10; &#10; existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))&#10; print(f'Found after: {existing}')&#10; if existing:&#10; print(f' idempotency_key: {existing.idempotency_key}')&#10;&quot;" />
</Execute>
<Execute>
<option name="pattern" value="python -c &quot;&#10;from sqlalchemy import create_engine, inspect, text&#10;from sqlalchemy.pool import StaticPool&#10;from app.database import Base&#10;from app.models import SubmissionAttempt&#10;&#10;engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)&#10;Base.metadata.create_all(engine)&#10;&#10;inspector = inspect(engine)&#10;columns = inspector.get_columns('submission_attempt')&#10;print('Columns:', [c['name'] for c in columns])&#10;&quot;" />
</Execute>
</list>
</option>
<option name="autoApprovedReads">
<list>
<Read>
<option name="pattern" value="/path/to/apps/web/src/pages/index.astro" />
</Read>
<Read>
<option name="pattern" value="/path/to/apps/web/src/lib/api.ts" />
</Read>
<Read>
<option name="pattern" value="/path/to/rf4_spotter/rf4_research/community_cli.py" />
</Read>
</list>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/rf4-help.iml" filepath="$PROJECT_DIR$/.idea/rf4-help.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+65
View File
@@ -0,0 +1,65 @@
# Repository Guidelines
## Project Structure & Module Organization
RF4 Spotter is an Astro/FastAPI/PostgreSQL application with an offline
research and media-ingestion toolkit. The main areas are:
- `apps/api/` — FastAPI application, routers, models, migrations, and API tests.
- `apps/web/` — Astro pages, components, styles, unit tests, and Playwright tests.
- `rf4_research/` — source parsers, media manifest tooling, and CLI commands.
- `tests/` — Python research/tooling tests and fixtures.
- `data/media/` — versioned manifest and content-addressed local media files.
- `docs/` — specification, roadmap, ADRs, runbooks, and acceptance procedures.
- `compose.yaml` — local PostgreSQL, API, web, and supporting services.
Keep generated reports and temporary downloads outside committed paths unless a
task explicitly requires versioning them.
## Build, Test, and Development Commands
From the repository root:
```bash
.venv/bin/pytest -q # Python suites
npm --prefix apps/web run check # Astro type/template checks
npm --prefix apps/web run build # Check and production build
npm --prefix apps/web run test:unit # Web unit tests
WEB_URL=http://127.0.0.1:4321 npm --prefix apps/web run test:e2e
docker compose up --build # Full local stack
```
For media work, use `.venv/bin/python -m rf4_research.media_cli --audit` and
respect the manifests cooldown and approval states. Do not hotlink or replace
approved media without explicit review.
## Coding Style & Naming Conventions
Use four-space indentation for Python and two spaces for Astro/TypeScript.
Prefer typed Python functions, `snake_case` for Python identifiers, and
`camelCase` for TypeScript variables/functions. Astro components use
`PascalCase.astro`; tests use descriptive `test_*.py` or `*.test.ts` names.
Keep UI text and data-source labels explicit and accessible; run `astro check`
before committing web changes.
## Testing Guidelines
Add focused regression tests beside the affected suite. Python tests use
pytest; web behavior uses Node unit tests and Playwright. Run the smallest
relevant test first, then the full suite before handoff. Never use live external
sources in tests; use fixtures or isolated Docker services.
## Commit & Pull Request Guidelines
Use imperative, concise commit subjects with the repositorys existing scope
style, such as `feat:`, `fix:`, `data:`, or `chore:`. Keep commits focused and
exclude unrelated user files. Pull requests should describe behavior changes,
verification commands, migration or configuration impact, and screenshots for
visual/UI work. Call out any media provenance, approval, or rollback decision.
## Security & Configuration Tips
Do not commit secrets, production `.env` files, tokens, or private payloads.
Use local fixtures and documented environment variables. Preserve the strict
CSP, admin authentication barriers, source attribution, and media provenance
when changing application code.
+6 -6
View File
@@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек
## Статус разработки
**Проверка 13 сентября 2026 (`787a506`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **157 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0016`; последний полный production bootstrap подтвердил Caddy, scheduler и браузерный сценарий отправки/модерации на предыдущей голове, а актуальная голова проверяется CI на чистой PostgreSQL. Реальные источники во время приёмки не опрашивались.
**Проверка 20 сентября 2026: локальный кодовый контур проходит сборочные и тестовые gates, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **191 passed, 1 skipped**; Astro check/build и web unit проходят. Граф миграций имеет единственную голову `0020`; OpenAPI artifact синхронизирован с FastAPI (`36 paths`). Изолированный production bootstrap с чистыми томами повторно пройден; живая БД, реальные секреты и импорт внешних источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
@@ -46,11 +46,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
Актуальный offline-срез от 16.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
Актуальный offline-срез от 20.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. Актуальный manifest содержит 704 записи: 466 approved, 226 superseded, 1 duplicate и 11 invalid; queue-представления больше нет. Опубликованы 253 fish-файла, все 149 найденных изображений снастей/приманок и 64 справочных материала; общий target снастей остаётся `null` до проверяемого полного счётчика. Водоёмы имеют отдельный canonical index из 19 карточек; detail-наполнение остаётся W02–W08.
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. `--queue-quality-upgrades` переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации. На 15.09 первая партия из 40 замен уже сохранена в карантине для сравнения и review; дальнейшие этапы ведутся в B21–B25 ROADMAP.
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Контур quality-upgrade обработал 226 прямых RF4DB-альтернатив: 252 из 253 рыбных изображений теперь имеют 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. `--queue-quality-upgrades` по-прежнему переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации; B21/B22 завершены, а browser-приёмка остаётся отдельным пунктом B25.
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
@@ -148,7 +148,7 @@ docker compose up --build
- readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>;
- консоль MinIO: <http://localhost:9001>.
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
Одноразовые контейнеры `migrate` и `minio-init` перед запуском API соответственно применяют `alembic upgrade head` и идемпотентно создают локальный `S3_BUCKET`; приложение само не создаёт схему или bucket. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL, MinIO и успешное завершение обоих init-контейнеров перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Здоровье импортов диагностическое и не мешает API или scheduler восстановиться после сбоя.
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд (до 128 ключей) и очищается после публикации, модерации или удаления через этот процесс API; изменения scheduler видны после TTL; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров.
@@ -184,7 +184,7 @@ docker compose up --build
## Что реализовано
- FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и линейные миграции Alembic до `0016`;
- PostgreSQL 17 и линейные миграции Alembic до `0020`;
- идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`;
@@ -0,0 +1,37 @@
"""add canonical waterbody provenance fields
Revision ID: 0017
Revises: 0016
"""
from alembic import op
import sqlalchemy as sa
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("waterbody", sa.Column("source_system", sa.String(length=50), nullable=True))
op.add_column("waterbody", sa.Column("source_external_id", sa.String(length=200), nullable=True))
op.add_column("waterbody", sa.Column("source_url", sa.Text(), nullable=True))
op.add_column("waterbody", sa.Column("description", sa.Text(), nullable=True))
op.add_column("waterbody", sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(
"uq_waterbody_source_identity",
"waterbody",
["source_system", "source_external_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_waterbody_source_identity", table_name="waterbody")
op.drop_column("waterbody", "source_checked_at")
op.drop_column("waterbody", "description")
op.drop_column("waterbody", "source_url")
op.drop_column("waterbody", "source_external_id")
op.drop_column("waterbody", "source_system")
@@ -0,0 +1,26 @@
"""store source coordinate text and precision
Revision ID: 0018
Revises: 0017
"""
from alembic import op
import sqlalchemy as sa
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("external_observation", sa.Column("coordinate_raw", sa.String(length=200), nullable=True))
op.add_column("external_observation", sa.Column("coordinate_precision", sa.String(length=20), nullable=True))
op.execute("UPDATE external_observation SET coordinate_precision = CASE WHEN x IS NOT NULL AND y IS NOT NULL THEN 'exact' ELSE 'missing' END")
op.alter_column("external_observation", "coordinate_precision", nullable=False, server_default="missing")
def downgrade() -> None:
op.drop_column("external_observation", "coordinate_precision")
op.drop_column("external_observation", "coordinate_raw")
@@ -0,0 +1,22 @@
"""store canonical waterbody fish count
Revision ID: 0019
Revises: 0018
"""
from alembic import op
import sqlalchemy as sa
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("waterbody", sa.Column("fish_species_count", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("waterbody", "fish_species_count")
@@ -0,0 +1,24 @@
"""store canonical waterbody detail facts
Revision ID: 0020
Revises: 0019
"""
from alembic import op
import sqlalchemy as sa
revision = "0020"
down_revision = "0019"
branch_labels = None
depends_on = None
def upgrade() -> None:
for name in ("source_aliases", "source_fish_species", "source_image_urls", "source_point_urls"):
op.add_column("waterbody", sa.Column(name, sa.JSON(), nullable=True))
def downgrade() -> None:
for name in ("source_point_urls", "source_image_urls", "source_fish_species", "source_aliases"):
op.drop_column("waterbody", name)
@@ -0,0 +1,65 @@
"""add canonical tackle items and rig components
Revision ID: 0021
Revises: 0020
"""
from alembic import op
import sqlalchemy as sa
revision = "0021"
down_revision = "0020"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tackle_item",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("category", sa.String(20), nullable=False),
sa.Column("subcategory", sa.String(100)),
sa.Column("brand", sa.String(100)),
sa.Column("family", sa.String(100)),
sa.Column("unlock_level", sa.Integer()),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("source_system", "source_external_id"),
sa.CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
op.create_table(
"rig",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
)
op.create_table(
"rig_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200)),
sa.UniqueConstraint("rig_id", "position"),
)
def downgrade() -> None:
op.drop_table("rig_component")
op.drop_table("rig")
op.drop_table("tackle_item")
@@ -0,0 +1,40 @@
"""preserve ordered gear evidence on catches
Revision ID: 0022
Revises: 0021
"""
from alembic import op
import sqlalchemy as sa
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"catch_tackle_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200), nullable=False),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("catch_report_id", "position"),
sa.CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
def downgrade() -> None:
op.drop_table("catch_tackle_component")
+27 -1
View File
@@ -61,6 +61,9 @@ def activity_rows(
confidence = min(confidence, 50)
elif len(players) == 2:
confidence = min(confidence, 65)
coordinate_precisions = {_coordinate_precision(item) for item in items}
coordinate_precision = max(coordinate_precisions, key=_precision_rank)
coordinate_sources = sorted({_source_system(item) for item in items})
latest = max(_aware(r.reported_at) for r in items)
baits = Counter(r.bait.name for r in items if r.bait)
freshness_text = _freshness_text(now - latest)
@@ -74,7 +77,10 @@ def activity_rows(
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
activity_score=activity, confidence_score=confidence,
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
sources=sorted({_source_system(item) for item in items}),
sources=coordinate_sources,
coordinate_precision=coordinate_precision,
coordinate_sources=coordinate_sources,
source_conflicts=_source_conflicts(items),
))
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
@@ -90,6 +96,26 @@ def _source_system(report: CatchReport) -> str:
return "manual-import"
def _source_conflicts(reports: list[CatchReport]) -> list[str]:
conflicts: set[str] = set()
for report in reports:
provenance = (report.raw_payload or {}).get("provenance", {})
values = provenance.get("conflicts", []) if isinstance(provenance, dict) else []
if isinstance(values, list):
conflicts.update(str(value).strip() for value in values if str(value).strip())
return sorted(conflicts)
def _coordinate_precision(report: CatchReport) -> str:
provenance = (report.raw_payload or {}).get("provenance", {})
value = provenance.get("coordinate_precision") if isinstance(provenance, dict) else None
return value if value in {"exact", "approximate", "area", "missing"} else "exact"
def _precision_rank(value: str) -> int:
return {"exact": 0, "approximate": 1, "area": 2, "missing": 3}[value]
def _aware(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
+25
View File
@@ -15,3 +15,28 @@ def audit_catalog(db: Session) -> dict[str, int]:
"incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0,
}
return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())}
def audit_waterbody_catalog(db: Session, expected_ids: set[str]) -> dict:
"""Check a verified RF4DB snapshot without withdrawing legacy rows."""
rows = list(db.scalars(select(Waterbody).where(Waterbody.source_system == "rf4db")))
observed_ids = [str(row.source_external_id) for row in rows if row.source_external_id]
observed = set(observed_ids)
duplicate_ids = sorted({item for item in observed_ids if observed_ids.count(item) > 1})
missing = sorted(expected_ids - observed)
unexpected = sorted(observed - expected_ids)
provenance_issues = sorted(
str(row.source_external_id)
for row in rows
if not row.source_external_id or not row.source_url or not row.source_checked_at
)
failures = len(missing) + len(duplicate_ids) + len(provenance_issues)
return {
"expected": len(expected_ids),
"observed": len(observed),
"missing_source_external_ids": missing,
"unexpected_source_external_ids": unexpected,
"duplicate_source_external_ids": duplicate_ids,
"provenance_issues": provenance_issues,
"failures": failures,
}
+66 -2
View File
@@ -8,10 +8,10 @@ from dataclasses import asdict
from .config import settings
from .database import SessionLocal
from .importer import import_records
from .community_importer import stage_observations
from .community_importer import stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
from .retention import RetentionPolicy, apply_retention
from .storage import delete_screenshot
from .catalog_audit import audit_catalog
from .catalog_audit import audit_catalog, audit_waterbody_catalog
from .community_scheduler import run_source, configured_sources
# Static registry for argparse choices — no DB required for --help
@@ -34,11 +34,20 @@ def main() -> int:
community = sub.add_parser("stage-community-json")
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
community.add_argument("--limit", type=int, default=500)
waterbodies = sub.add_parser("import-waterbody-catalog")
waterbodies.add_argument("--input", required=True, help="JSON snapshot path or - for stdin")
waterbodies.add_argument("--limit", type=int, default=100)
detail = sub.add_parser("import-waterbody-detail")
detail.add_argument("--input", required=True, help="JSON detail snapshot path")
details = sub.add_parser("import-waterbody-details")
details.add_argument("--input", required=True, help="JSON array of detail snapshots")
fetch_community = sub.add_parser("fetch-community")
fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES)
cleanup = sub.add_parser("cleanup-retention")
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
sub.add_parser("audit-catalog")
waterbody_audit = sub.add_parser("audit-waterbody-catalog")
waterbody_audit.add_argument("--input", required=True, help="JSON snapshot path")
args = parser.parse_args()
with SessionLocal() as session:
if args.command == "import-records":
@@ -57,6 +66,43 @@ def main() -> int:
parser.error("input must be a JSON array")
created, updated = stage_observations(session, payload[:args.limit])
print(f"staged: created={created} updated={updated}")
elif args.command == "import-waterbody-catalog":
if not 1 <= args.limit <= 500:
parser.error("--limit must be between 1 and 500")
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
try:
snapshot = json.load(stream)
finally:
if stream is not sys.stdin:
stream.close()
if isinstance(snapshot, dict):
payload = snapshot.get("items")
source_system = snapshot.get("source_system")
if isinstance(payload, list) and isinstance(source_system, str):
payload = [
{"source_system": source_system, **item}
for item in payload if isinstance(item, dict)
]
else:
payload = snapshot
if not isinstance(payload, list):
parser.error("input must be a JSON array or an object with an items array")
created, updated = upsert_waterbody_catalog(session, payload[:args.limit])
print(f"waterbodies: created={created} updated={updated}")
elif args.command == "import-waterbody-detail":
with open(args.input, encoding="utf-8") as stream:
payload = json.load(stream)
if not isinstance(payload, dict):
parser.error("input must be a JSON object")
update_waterbody_detail(session, payload)
print(f"waterbody detail: updated={payload.get('source_external_id', 'unknown')}")
elif args.command == "import-waterbody-details":
with open(args.input, encoding="utf-8") as stream:
payload = json.load(stream)
if not isinstance(payload, list):
parser.error("input must be a JSON array")
created, updated = update_waterbody_details(session, payload)
print(f"waterbody details: created={created} updated={updated}")
elif args.command == "fetch-community":
# A09: Verify source is enabled at runtime (not just in static choices)
enabled = configured_sources()
@@ -76,6 +122,24 @@ def main() -> int:
)
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
elif args.command == "audit-waterbody-catalog":
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
try:
snapshot = json.load(stream)
finally:
if stream is not sys.stdin:
stream.close()
items = snapshot.get("items") if isinstance(snapshot, dict) else snapshot
if not isinstance(items, list):
parser.error("input must be a JSON array or an object with an items array")
expected_ids = {
str(item["source_external_id"])
for item in items
if isinstance(item, dict) and item.get("source_external_id")
}
result = audit_waterbody_catalog(session, expected_ids)
print(json.dumps(result, ensure_ascii=False))
return 1 if result["failures"] else 0
else:
result = audit_catalog(session)
print(json.dumps(result, ensure_ascii=False))
+167
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import hashlib
import re
from datetime import datetime, timezone
from typing import Any, Iterable
from urllib.parse import urlparse
@@ -26,12 +28,153 @@ SOURCE_HOSTS = {
"rf4map": {"rf4map.ru"},
"rf4posts-spot": {"rf4-posts.com"},
}
COORDINATE_PRECISIONS = frozenset({"exact", "approximate", "area", "missing"})
class CommunityImportError(ValueError):
pass
def upsert_waterbody_catalog(
session: Session, rows: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
) -> tuple[int, int]:
"""Apply a validated canonical waterbody snapshot without destructive sync.
Rows are matched by the RF4DB source identity first and by an exact existing
name second. Missing rows are deliberately left untouched: an incomplete
response must never withdraw a previously known waterbody.
"""
fetched_at = fetched_at or datetime.now(timezone.utc)
created = updated = 0
for raw in rows:
payload = _json_payload(raw)
if payload.get("source_system") != "rf4db":
raise CommunityImportError("waterbody catalog requires source_system=rf4db")
external_id = _required(payload, "source_external_id", 200)
name = _required(payload, "name", 200)
source_url = _required(payload, "source_url", 2000)
parsed_url = urlparse(source_url)
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com"}:
raise CommunityImportError("waterbody source_url does not match rf4db")
unlock_level = _integer(payload.get("unlock_level"), minimum=0, maximum=1_000)
unlock_label = _required(payload, "unlock_label", 50)
fish_species_count = _integer(payload.get("fish_species_count"), minimum=0, maximum=10_000)
if fish_species_count is None:
raise CommunityImportError("invalid fish_species_count")
item = session.scalar(select(Waterbody).where(
Waterbody.source_system == "rf4db",
Waterbody.source_external_id == external_id,
))
if item is None:
item = session.scalar(select(Waterbody).where(Waterbody.name_ru == name))
if item is None:
item = Waterbody(
slug=_catalog_slug(session, name, external_id),
name_ru=name,
unlock_level=unlock_level,
)
session.add(item)
created += 1
else:
updated += 1
item.name_ru = name
item.unlock_level = unlock_level
item.fish_species_count = fish_species_count
item.source_system = "rf4db"
item.source_external_id = external_id
item.source_url = source_url
item.source_checked_at = fetched_at
session.commit()
return created, updated
def update_waterbody_detail(
session: Session, detail: dict[str, Any], *, fetched_at: datetime | None = None,
) -> bool:
"""Persist one complete RF4DB detail snapshot without assigning media roles."""
fetched_at = fetched_at or datetime.now(timezone.utc)
payload = _validate_waterbody_detail(detail)
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
session.commit()
return True
def update_waterbody_details(
session: Session, details: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
) -> tuple[int, int]:
"""Validate and apply a detail batch in one transaction."""
fetched_at = fetched_at or datetime.now(timezone.utc)
payloads = [_validate_waterbody_detail(detail) for detail in details]
external_ids = [str(payload["source_external_id"]) for payload in payloads]
if len(external_ids) != len(set(external_ids)):
raise CommunityImportError("waterbody detail batch contains duplicate source identities")
updated = 0
for payload in payloads:
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
updated += 1
session.commit()
return 0, updated
def _validate_waterbody_detail(detail: dict[str, Any]) -> dict[str, Any]:
payload = _json_payload(detail)
if payload.get("source_system") != "rf4db":
raise CommunityImportError("waterbody detail requires source_system=rf4db")
external_id = _required(payload, "source_external_id", 200)
source_url = _required(payload, "source_url", 2000)
parsed_url = urlparse(source_url)
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com", "download.rf4db.com"}:
raise CommunityImportError("waterbody detail source_url does not match rf4db")
_required(payload, "name", 200)
_optional(payload, "description", 20_000)
_string_list(payload, "aliases", 100, 200)
_string_list(payload, "fish_species", 10_000, 200)
_string_list(payload, "image_urls", 100, 2_000)
_string_list(payload, "point_urls", 10_000, 2_000)
return payload
def _apply_waterbody_detail(session: Session, payload: dict[str, Any], *, fetched_at: datetime) -> None:
external_id = str(payload["source_external_id"])
source_url = str(payload["source_url"])
item = session.scalar(select(Waterbody).where(
Waterbody.source_system == "rf4db", Waterbody.source_external_id == external_id,
))
if item is None:
raise CommunityImportError("waterbody detail has no imported catalog identity")
item.description = _optional(payload, "description", 20_000)
item.source_aliases = _string_list(payload, "aliases", 100, 200)
item.source_fish_species = _string_list(payload, "fish_species", 10_000, 200)
item.source_image_urls = _string_list(payload, "image_urls", 100, 2_000)
item.source_point_urls = _string_list(payload, "point_urls", 10_000, 2_000)
item.source_url = source_url
item.source_checked_at = fetched_at
def _catalog_slug(session: Session, name: str, external_id: str) -> str:
base = re.sub(r"[^a-z0-9а-яё]+", "-", name.casefold(), flags=re.IGNORECASE).strip("-")
base = base or "waterbody"
candidate = base[:100]
if session.scalar(select(Waterbody.id).where(Waterbody.slug == candidate)) is None:
return candidate
suffix = hashlib.sha256(external_id.encode()).hexdigest()[:10]
return f"{base[:89]}-{suffix}"
def _string_list(payload: dict[str, Any], key: str, max_items: int, max_length: int) -> list[str]:
value = payload.get(key)
if not isinstance(value, list) or len(value) > max_items:
raise CommunityImportError(f"invalid {key}")
result = []
for item in value:
text = str(item).strip()
if not text or len(text) > max_length:
raise CommunityImportError(f"invalid {key}")
result.append(text)
return list(dict.fromkeys(result))
def stage_observations(
session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
) -> tuple[int, int]:
@@ -65,6 +208,8 @@ def stage_observations(
"waterbody_external_id": _optional(payload, "waterbody_external_id", 200),
"x": _integer(payload.get("x"), maximum=10_000),
"y": _integer(payload.get("y"), maximum=10_000),
"coordinate_raw": _coordinate_raw(payload),
"coordinate_precision": _coordinate_precision(payload),
"weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000),
"published_at": _datetime(payload.get("published_at")),
"last_seen_at": fetched_at, "payload": payload,
@@ -83,6 +228,7 @@ def stage_observations(
changed = any(getattr(observation, key) != values[key] for key in (
"source_url", "fish_name", "fish_external_id", "waterbody_name",
"waterbody_external_id", "x", "y", "weight_g",
"coordinate_raw", "coordinate_precision",
)) or observation.payload != payload
if observation.status != "rejected" and changed and observation.catch_report is not None:
observation.catch_report.moderation_status = ModerationStatus.pending
@@ -186,6 +332,27 @@ def _optional(payload: dict[str, Any], key: str, limit: int) -> str | None:
return value or None
def _coordinate_raw(payload: dict[str, Any]) -> str | None:
value = str(payload.get("coordinate_raw") or "").strip()
if len(value) > 200:
raise CommunityImportError("invalid coordinate_raw")
if value:
return value
x, y = payload.get("x"), payload.get("y")
return f"{x}:{y}" if isinstance(x, int) and isinstance(y, int) else None
def _coordinate_precision(payload: dict[str, Any]) -> str:
value = str(payload.get("coordinate_precision") or "").strip().casefold()
if not value:
return "exact" if isinstance(payload.get("x"), int) and isinstance(payload.get("y"), int) else "missing"
if value not in COORDINATE_PRECISIONS:
raise CommunityImportError("invalid coordinate_precision")
if value == "exact" and (not isinstance(payload.get("x"), int) or not isinstance(payload.get("y"), int)):
raise CommunityImportError("exact coordinates require x and y")
return value
def _integer(value: Any, *, minimum: int = -10_000, maximum: int) -> int | None:
if value is None:
return None
+28 -6
View File
@@ -11,6 +11,8 @@ from .models import (
Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation,
Fish, ModerationStatus, SourceType, Spot, Waterbody,
)
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
class ExternalReviewError(ValueError):
@@ -19,7 +21,7 @@ class ExternalReviewError(ValueError):
def map_observation(
session: Session, observation: ExternalObservation, fish: Fish, waterbody: Waterbody,
*, note: str | None = None,
*, note: str | None = None, commit: bool = True,
) -> ExternalObservation:
if observation.status == "published":
raise ExternalReviewError("published observation cannot be remapped")
@@ -41,7 +43,8 @@ def map_observation(
observation.status = "ready" if _complete(observation) else "mapped"
_save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish)
_save_alias(session, observation, "waterbody", observation.waterbody_external_id or observation.waterbody_name, waterbody=waterbody)
session.commit()
if commit:
session.commit()
return observation
@@ -59,17 +62,22 @@ def suggest_aliases(session: Session, observation: ExternalObservation) -> tuple
return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None)
def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation:
def reject_observation(
session: Session, observation: ExternalObservation, *, reason: str, commit: bool = True,
) -> ExternalObservation:
if observation.status == "published":
raise ExternalReviewError("published observation cannot be rejected")
observation.status = "rejected"
observation.review_note = reason
observation.reviewed_at = datetime.now(timezone.utc)
session.commit()
if commit:
session.commit()
return observation
def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport:
def publish_observation(
session: Session, observation: ExternalObservation, *, commit: bool = True,
) -> CatchReport:
if observation.catch_report is not None and observation.status == "published":
return observation.catch_report
if observation.status == "rejected":
@@ -104,6 +112,8 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
"external_observation_id": str(observation.id),
"source_system": observation.source_system,
"source_external_id": observation.source_external_id,
"coordinate_raw": observation.coordinate_raw,
"coordinate_precision": observation.coordinate_precision,
},
"original": observation.payload,
},
@@ -116,10 +126,22 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
setattr(report, key, value)
session.add(report)
session.flush()
replace_tackle_components(
session,
report,
from_catch_fields(
bait=observation.payload.get("bait"),
rig_type=observation.payload.get("rig_type"),
),
source_system=observation.source_system,
source_url=observation.source_url,
raw_payload={"origin": "community_observation", "observation_id": str(observation.id)},
)
observation.catch_report = report
observation.status = "published"
observation.reviewed_at = now
session.commit()
if commit:
session.commit()
return report
+10
View File
@@ -16,6 +16,8 @@ from .models import (
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
OfficialRecordImport, SourceType, Waterbody,
)
from .tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
@@ -222,6 +224,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
))
run.rows_updated += 1
replace_tackle_components(
session,
report,
from_catch_fields(bait=raw.bait, rig_type=None),
source_system="rf4-official",
source_url=url,
raw_payload={"origin": "official_record", "source_external_id": key},
)
run.status = ImportStatus.success
run.finished_at = datetime.now(timezone.utc)
session.commit()
+2
View File
@@ -14,6 +14,7 @@ from .dependencies import Db
from .logging_config import configure_logging
from .readiness import readiness_report
from .routers.activity import router as activity_router
from .routers.analytics import router as analytics_router
from .routers.admin import router as admin_router
from .routers.catalog import router as catalog_router
from .routers.media import router as media_router
@@ -90,6 +91,7 @@ def ready(db: Db) -> JSONResponse:
app.include_router(catalog_router)
app.include_router(media_router)
app.include_router(activity_router)
app.include_router(analytics_router)
app.include_router(public_data_router)
app.include_router(admin_router)
app.include_router(submissions_router)
+47 -5
View File
@@ -6,9 +6,25 @@ from pathlib import Path
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve()
WATERBODY_MEDIA_ROLES = {"waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot"}
TACKLE_MEDIA_ROLES = {"tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"}
KNOWN_MEDIA_ROLES = WATERBODY_MEDIA_ROLES | TACKLE_MEDIA_ROLES
MEDIA_ROLES_BY_ENTITY = {"waterbody": WATERBODY_MEDIA_ROLES, "tackle": TACKLE_MEDIA_ROLES}
def published_assets(entity_type: str | None = None) -> list[dict]:
def _public_role_allowed(entity_type: str | None, role: object) -> bool:
if role is None:
return True
if not isinstance(role, str) or role not in KNOWN_MEDIA_ROLES:
return False
if entity_type == "waterbody":
return role in WATERBODY_MEDIA_ROLES
if entity_type == "tackle":
return role in TACKLE_MEDIA_ROLES
return False
def published_assets(entity_type: str | None = None, media_role: str | None = None) -> list[dict]:
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
result = []
for item in manifest.get("assets", []):
@@ -16,12 +32,17 @@ def published_assets(entity_type: str | None = None) -> list[dict]:
continue
if entity_type and item.get("entity_type") != entity_type:
continue
if media_role and item.get("media_role") != media_role:
continue
if not _public_role_allowed(item.get("entity_type"), item.get("media_role")):
continue
source_page = str(item.get("source_page") or "")
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
result.append({
"id": item["sha256"],
"entity_type": item.get("entity_type"),
"entity_key": item.get("entity_key"),
"media_role": item.get("media_role"),
"label": item.get("label"),
"width": item.get("width"),
"height": item.get("height"),
@@ -49,15 +70,32 @@ def published_file(digest: str) -> tuple[Path, str] | None:
return None
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
if not item:
media_type = None
local_path = None
if item:
media_type = item.get("content_type")
local_path = item.get("local_path")
else:
for row in manifest.get("assets", []):
if row.get("status") != "approved":
continue
variant = next((candidate for candidate in row.get("derivatives", []) if candidate.get("sha256") == digest), None)
if variant:
media_type = variant.get("content_type")
local_path = variant.get("local_path")
break
if not local_path:
return None
target = (MEDIA_ROOT / item["local_path"]).resolve()
target = (MEDIA_ROOT / local_path).resolve()
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
return None
return target, str(item["content_type"])
return target, str(media_type or "application/octet-stream")
def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]:
def review_assets(
entity_type: str | None = None, status: str | None = None, media_role: str | None = None,
query: str | None = None,
) -> list[dict]:
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
result = []
for item in manifest.get("assets", []):
@@ -66,6 +104,10 @@ def review_assets(entity_type: str | None = None, status: str | None = None) ->
continue
if entity_type and item.get("entity_type") != entity_type:
continue
if media_role and item.get("media_role") != media_role:
continue
if query and query.casefold() not in str(item.get("label") or "").casefold():
continue
digest = str(item.get("sha256") or "")
if len(digest) != 64 or not item.get("local_path"):
continue
+95
View File
@@ -49,6 +49,16 @@ class Waterbody(Base):
slug: Mapped[str] = mapped_column(String(100), unique=True)
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
unlock_level: Mapped[int | None]
fish_species_count: Mapped[int | None]
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
source_aliases: Mapped[list[str] | None] = mapped_column(JSON)
source_fish_species: Mapped[list[str] | None] = mapped_column(JSON)
source_image_urls: Mapped[list[str] | None] = mapped_column(JSON)
source_point_urls: Mapped[list[str] | None] = mapped_column(JSON)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Bait(Base):
@@ -59,6 +69,61 @@ class Bait(Base):
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
class TackleItem(Base):
"""Canonical gear item; legacy Bait rows remain source-compatible."""
__tablename__ = "tackle_item"
__table_args__ = (
UniqueConstraint("source_system", "source_external_id"),
CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
category: Mapped[str] = mapped_column(String(20))
subcategory: Mapped[str | None] = mapped_column(String(100))
brand: Mapped[str | None] = mapped_column(String(100))
family: Mapped[str | None] = mapped_column(String(100))
unlock_level: Mapped[int | None]
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
rig_components: Mapped[list["RigComponent"]] = relationship(back_populates="tackle_item")
class Rig(Base):
"""A named rig/setup kept separate from individual tackle items."""
__tablename__ = "rig"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
components: Mapped[list["RigComponent"]] = relationship(back_populates="rig")
class RigComponent(Base):
__tablename__ = "rig_component"
__table_args__ = (UniqueConstraint("rig_id", "position"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
rig_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rig.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str | None] = mapped_column(String(200))
rig: Mapped[Rig] = relationship(back_populates="components")
tackle_item: Mapped[TackleItem | None] = relationship(back_populates="rig_components")
class Spot(Base):
__tablename__ = "spot"
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
@@ -100,6 +165,34 @@ class CatchReport(Base):
spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship()
bait: Mapped[Bait | None] = relationship()
tackle_components: Mapped[list["CatchTackleComponent"]] = relationship(back_populates="catch_report")
class CatchTackleComponent(Base):
"""Ordered gear evidence; unresolved raw values are valid and preserved."""
__tablename__ = "catch_tackle_component"
__table_args__ = (
UniqueConstraint("catch_report_id", "position"),
CheckConstraint(
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
name="ck_catch_tackle_one_canonical_target",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
rig_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rig.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str] = mapped_column(String(200))
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSON)
catch_report: Mapped[CatchReport] = relationship(back_populates="tackle_components")
tackle_item: Mapped[TackleItem | None] = relationship()
rig: Mapped[Rig | None] = relationship()
class OfficialRecordImport(Base):
@@ -187,6 +280,8 @@ class ExternalObservation(Base):
waterbody_external_id: Mapped[str | None] = mapped_column(String(200))
x: Mapped[int | None]
y: Mapped[int | None]
coordinate_raw: Mapped[str | None] = mapped_column(String(200))
coordinate_precision: Mapped[str] = mapped_column(String(20), default="missing")
weight_g: Mapped[int | None]
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+18 -5
View File
@@ -5,9 +5,9 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from ..activity import activity_rows
from ..activity import _source_conflicts, activity_rows
from ..config import settings
from ..dependencies import Db
from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody
@@ -68,7 +68,15 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
def count_since(delta: timedelta) -> int:
return sum(aware(report.reported_at) >= now - delta for report in reports)
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)])
provenance = [
(report.raw_payload or {}).get("provenance", {})
for report in reports
if isinstance((report.raw_payload or {}).get("provenance", {}), dict)
]
precisions = [item.get("coordinate_precision") for item in provenance]
precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact")
sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"]
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources, source_conflicts=_source_conflicts(reports))
def _report_source(report: CatchReport) -> str:
@@ -83,8 +91,13 @@ def _report_source(report: CatchReport) -> str:
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
_spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url) for report in reports]
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait), selectinload(CatchReport.tackle_components)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, fishing_method=report.fishing_method, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url, tackle_components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
"rig_id": component.rig_id, "source_system": component.source_system,
"source_url": component.source_url,
} for component in sorted(report.tackle_components, key=lambda value: value.position)]) for report in reports]
@router.get("/api/v1/spots/{spot_id}/timeline")
+97 -3
View File
@@ -17,10 +17,10 @@ from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade
from ..media_catalog import MEDIA_ROOT, review_assets, review_file
from ..media_catalog import KNOWN_MEDIA_ROLES, MEDIA_ROLES_BY_ENTITY, MEDIA_ROOT, review_assets, review_file
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationBulkAction, ExternalObservationBulkDecision, ExternalObservationBulkMapping, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate
from ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware
@@ -37,10 +37,16 @@ def admin_media_catalog(
_: Annotated[str, Depends(_admin)],
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"),
q: str | None = Query(None, max_length=100),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[AdminMediaReviewOut]:
return review_assets(entity_type, status)[offset:offset + limit]
if media_role and media_role not in KNOWN_MEDIA_ROLES:
raise HTTPException(status_code=422, detail="unknown media role")
if media_role and (not entity_type or media_role not in MEDIA_ROLES_BY_ENTITY.get(entity_type, set())):
raise HTTPException(status_code=422, detail="media role is incompatible with entity type")
return review_assets(entity_type, status, media_role, q.strip() if q else None)[offset:offset + limit]
@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse)
@@ -343,6 +349,34 @@ def admin_map_external_observation(
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.patch("/api/v1/admin/external-observations/bulk-mapping", response_model=ExternalObservationBulkResult)
def admin_map_external_observations_bulk(
payload: ExternalObservationBulkMapping, db: Db, _: Annotated[str, Depends(_admin)],
) -> ExternalObservationBulkResult:
ids = [item.id for item in payload.items]
observations = list(db.scalars(select(ExternalObservation).where(ExternalObservation.id.in_(ids)).with_for_update()))
by_id = {item.id: item for item in observations}
expected = {item.id: item.expected_version for item in payload.items}
if len(by_id) != len(ids) or any(
item.status in {"published", "withdrawn"} or item.moderation_version != expected[item.id]
for item in observations
):
raise HTTPException(status_code=409, detail="one or more observations changed; reload the queue")
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
if fish is None or waterbody is None:
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
try:
for item in observations:
item.moderation_version += 1
map_observation(db, item, fish, waterbody, note=payload.note, commit=False)
db.commit()
except ExternalReviewError as exc:
db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
return ExternalObservationBulkResult(updated=len(observations))
@router.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
def admin_publish_external_observation(
observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)],
@@ -361,6 +395,30 @@ def admin_publish_external_observation(
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
@router.post("/api/v1/admin/external-observations/bulk-publish", response_model=ExternalObservationBulkResult)
def admin_publish_external_observations_bulk(
payload: ExternalObservationBulkAction, db: Db, _: Annotated[str, Depends(_admin)],
) -> ExternalObservationBulkResult:
ids = [item.id for item in payload.items]
observations = list(db.scalars(select(ExternalObservation).where(ExternalObservation.id.in_(ids)).with_for_update()))
expected = {item.id: item.expected_version for item in payload.items}
if len({item.id for item in observations}) != len(ids) or any(
item.status in {"rejected", "withdrawn"} or item.moderation_version != expected[item.id]
for item in observations
):
raise HTTPException(status_code=409, detail="one or more observations changed; reload the queue")
try:
for item in observations:
item.moderation_version += 1
publish_observation(db, item, commit=False)
db.commit()
except ExternalReviewError as exc:
db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
public_cache.invalidate()
return ExternalObservationBulkResult(updated=len(observations))
@router.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut)
def admin_reject_external_observation(
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
@@ -378,6 +436,21 @@ def admin_reject_external_observation(
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.patch("/api/v1/admin/external-observations/bulk-reject", response_model=ExternalObservationBulkResult)
def admin_reject_external_observations_bulk(payload: ExternalObservationBulkDecision, db: Db, _: Annotated[str, Depends(_admin)]) -> ExternalObservationBulkResult:
ids = [item.id for item in payload.items]
observations = list(db.scalars(select(ExternalObservation).where(ExternalObservation.id.in_(ids)).with_for_update()))
by_id = {item.id: item for item in observations}
expected = {item.id: item.expected_version for item in payload.items}
if len(by_id) != len(ids) or any(item.status in {"published", "rejected", "withdrawn"} or item.moderation_version != expected[item.id] for item in observations):
raise HTTPException(status_code=409, detail="one or more observations changed; reload the queue")
for item in observations:
item.moderation_version += 1
reject_observation(db, item, reason=payload.reason, commit=False)
db.commit()
return ExternalObservationBulkResult(updated=len(observations))
@router.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
@@ -400,6 +473,27 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
@router.post("/api/v1/admin/catch-reports/bulk", response_model=ModerationBulkResult)
def moderate_reports_bulk(payload: ModerationBulkUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> ModerationBulkResult:
if payload.status == "rejected" and not (payload.reason or "").strip():
raise HTTPException(status_code=422, detail="rejection reason required")
ids = [item.id for item in payload.items]
reports = list(db.scalars(select(CatchReport).where(CatchReport.id.in_(ids)).with_for_update()))
by_id = {report.id: report for report in reports}
if len(by_id) != len(ids) or any(report.source_type != SourceType.user or report.deleted_at is not None or report.moderation_status != ModerationStatus.pending for report in reports):
raise HTTPException(status_code=409, detail="one or more reports are no longer pending")
expected = {item.id: item.expected_version for item in payload.items}
if any(report.moderation_version != expected[report.id] for report in reports):
raise HTTPException(status_code=409, detail="one or more reports changed; reload the queue")
for report in reports:
previous = report.moderation_status
report.moderation_status = ModerationStatus(payload.status)
report.moderation_version += 1
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
db.commit(); public_cache.invalidate()
return ModerationBulkResult(updated=len(reports))
@router.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)], expected_version: int = Query(ge=0)) -> Response:
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from math import exp, log
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload, selectinload
from ..dependencies import Db
from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import TackleCombinationOut
router = APIRouter()
FRESHNESS_HALF_LIFE_HOURS = 12.5
def _age_hours(value: datetime, now: datetime) -> float:
observed_at = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
return max(0.0, (now - observed_at).total_seconds() / 3600)
@router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
def tackle_combinations(
db: Db,
waterbody: str | None = None,
fish: str | None = None,
method: str | None = None,
role: str | None = None,
hours: int = Query(72, ge=24, le=168),
min_samples: int = Query(3, ge=1, le=100),
min_players: int = Query(2, ge=1, le=100),
) -> list[TackleCombinationOut]:
if role is not None and role not in {"lure", "bait", "rig", "rod", "reel", "line", "hook", "float", "sinker"}:
raise HTTPException(status_code=422, detail="invalid tackle role")
now = datetime.now(timezone.utc)
query = select(CatchReport).options(
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
selectinload(CatchReport.tackle_components),
).where(
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.deleted_at.is_(None),
CatchReport.reported_at >= now - timedelta(hours=hours),
)
if waterbody:
query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody)
if fish:
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
if method:
query = query.where(CatchReport.fishing_method == method)
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
for report in db.scalars(query):
for component in report.tackle_components:
if role and component.role != role:
continue
if component.raw_value.strip():
groups[(component.role, component.raw_value.strip())].append(report)
result = []
for (component_role, value), reports in groups.items():
unique_reports = {report.id: report for report in reports}
item_ids = {
component.tackle_item_id
for report in unique_reports.values()
for component in report.tackle_components
if component.role == component_role and component.raw_value.strip() == value and component.tackle_item_id is not None
}
rig_ids = {
component.rig_id
for report in unique_reports.values()
for component in report.tackle_components
if component.role == component_role and component.raw_value.strip() == value and component.rig_id is not None
}
canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids else None
canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids else None
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
catches = len(unique_reports)
unique_players = len(players)
last_seen = max(report.reported_at for report in unique_reports.values())
freshness_score = round(sum(
exp(-_age_hours(report.reported_at, now) * log(2) / FRESHNESS_HALF_LIFE_HOURS)
for report in unique_reports.values()
) / catches * 100)
enough = catches >= min_samples and unique_players >= min_players
result.append(TackleCombinationOut(
role=component_role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id,
catches=catches, unique_players=unique_players,
last_seen_at=last_seen, freshness_score=freshness_score,
status="recommendation" if enough else "insufficient_data",
explanation=(
"Достаточно независимых наблюдений для рекомендации."
if enough else
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
),
))
return sorted(result, key=lambda item: (item.status != "recommendation", -item.freshness_score, -item.catches, -item.unique_players, item.role, item.value))
+108 -4
View File
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Query
from sqlalchemy import select
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from ..dependencies import Db
from ..models import Bait, CatchReport, Fish, ModerationStatus, Spot, Waterbody
from ..schemas import BaitOut, FishOut, WaterbodyOut
from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, RigComponent, Spot, TackleItem, Waterbody
from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, TackleItemRigOut, WaterbodyOut
router = APIRouter()
@@ -23,6 +26,107 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
def _item_missing_fields(item: TackleItem) -> list[str]:
return [field for field, value in (
("subcategory", item.subcategory), ("brand", item.brand),
("family", item.family), ("unlock_level", item.unlock_level),
("source_url", item.source_url), ("source_checked_at", item.source_checked_at),
) if value is None]
def _rig_missing_fields(rig: Rig) -> list[str]:
return [field for field, value in (
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
) if value is None]
@router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut)
def tackle_items(
db: Db,
category: str | None = Query(None, pattern="^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$"),
q: str | None = None,
brand: str | None = None,
family: str | None = None,
unlock_level: int | None = Query(None, ge=0),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedTackleItemOut:
if q is not None and len(q) > 100:
raise HTTPException(status_code=422, detail="search query is too long")
query = select(TackleItem)
if q and q.strip():
query = query.where(TackleItem.name.ilike(f"%{q.strip()}%"))
if category:
query = query.where(TackleItem.category == category)
if brand:
query = query.where(TackleItem.brand == brand)
if family:
query = query.where(TackleItem.family == family)
if unlock_level is not None:
query = query.where(TackleItem.unlock_level == unlock_level)
total = db.scalar(query.with_only_columns(func.count(TackleItem.id), maintain_column_froms=True).order_by(None)) or 0
items = list(db.scalars(query.order_by(TackleItem.name, TackleItem.id).offset(offset).limit(limit)))
return PaginatedTackleItemOut(
items=[TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) for item in items],
total=total, limit=limit, offset=offset,
)
@router.get("/api/v1/tackle/items/{item_id}", response_model=TackleItemOut)
def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
item = db.get(TackleItem, item_id)
if item is None:
raise HTTPException(status_code=404, detail="tackle item not found")
rigs = list(db.scalars(select(Rig).join(RigComponent, RigComponent.rig_id == Rig.id).where(RigComponent.tackle_item_id == item_id).options(selectinload(Rig.components)).distinct().order_by(Rig.name, Rig.id)))
related = [TackleItemRigOut(id=rig.id, name=rig.name, source_system=rig.source_system, source_checked_at=rig.source_checked_at, component_count=len(rig.components)) for rig in rigs]
return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item), "related_rigs": related})
@router.get("/api/v1/tackle/rigs", response_model=PaginatedRigOut)
def tackle_rigs(
db: Db,
q: str | None = None,
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedRigOut:
if q is not None and len(q) > 100:
raise HTTPException(status_code=422, detail="search query is too long")
query = select(Rig).options(selectinload(Rig.components))
if q and q.strip():
query = query.where(Rig.name.ilike(f"%{q.strip()}%"))
total_query = select(func.count(Rig.id))
if q and q.strip():
total_query = total_query.where(Rig.name.ilike(f"%{q.strip()}%"))
total = db.scalar(total_query) or 0
rigs = list(db.scalars(query.order_by(Rig.name, Rig.id).offset(offset).limit(limit)))
return PaginatedRigOut(
items=[RigSummaryOut(
id=rig.id, name=rig.name, source_system=rig.source_system,
source_external_id=rig.source_external_id, source_url=rig.source_url,
source_checked_at=rig.source_checked_at, missing_fields=_rig_missing_fields(rig),
component_count=len(rig.components),
) for rig in rigs],
total=total, limit=limit, offset=offset,
)
@router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut)
def rig_detail(rig_id: UUID, db: Db) -> RigOut:
rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id))
if rig is None:
raise HTTPException(status_code=404, detail="rig not found")
missing = _rig_missing_fields(rig)
return RigOut(
id=rig.id, name=rig.name, source_system=rig.source_system,
source_external_id=rig.source_external_id, source_url=rig.source_url,
source_checked_at=rig.source_checked_at, missing_fields=missing,
components=[{
"id": component.id, "role": component.role, "position": component.position,
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
} for component in sorted(rig.components, key=lambda value: value.position)],
)
@router.get("/api/v1/public-spot-pages")
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
+10 -3
View File
@@ -1,15 +1,22 @@
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from ..media_catalog import published_assets, published_file
from ..media_catalog import KNOWN_MEDIA_ROLES, published_assets, published_file
router = APIRouter()
@router.get("/api/v1/media/catalog")
def media_catalog(entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$")) -> list[dict]:
return published_assets(entity_type)
def media_catalog(
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"),
) -> list[dict]:
if media_role and entity_type not in {"waterbody", "tackle"}:
raise HTTPException(status_code=422, detail="media_role requires waterbody or tackle entity_type")
if media_role and media_role not in KNOWN_MEDIA_ROLES:
raise HTTPException(status_code=422, detail="unknown media role")
return published_assets(entity_type, media_role)
@router.get("/api/v1/media/assets/{digest}", response_class=FileResponse)
+9 -2
View File
@@ -1,13 +1,14 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query
from sqlalchemy import func, select
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import joinedload
from ..config import settings
from ..dependencies import Db
from ..models import (
CatchReport,
Bait,
CommunityImportRun,
DataSource,
ExternalObservation,
@@ -117,9 +118,12 @@ def records(
fish: str | None = None,
waterbody: str | None = None,
category: str | None = None,
q: str | None = None,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> PaginatedOfficialRecordOut:
if q is not None and len(q) > 100:
raise HTTPException(status_code=422, detail="record search query is too long")
query = select(CatchReport).options(
joinedload(CatchReport.fish),
joinedload(CatchReport.waterbody),
@@ -131,6 +135,9 @@ def records(
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
if category:
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
if q and q.strip():
needle = f"%{q.strip()}%"
query = query.outerjoin(Bait, CatchReport.bait_id == Bait.id).where(or_(CatchReport.player_name.ilike(needle), Bait.name.ilike(needle)))
total = db.scalar(
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
) or 0
+10
View File
@@ -21,6 +21,8 @@ from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, Source
from ..schemas import CatchReportAccepted, CatchReportCreate
from ..storage import ScreenshotError, upload_screenshot
from ..submission_security import check_rate_limit
from ..tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
router = APIRouter()
logger = logging.getLogger("rf4.api.submissions")
@@ -60,6 +62,14 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, id
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
db.add(report)
replace_tackle_components(
db,
report,
from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type),
source_system="user",
source_url=payload.source_url,
raw_payload={"origin": "user_submission"},
)
if key_hash:
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
try:
+158
View File
@@ -20,6 +20,16 @@ class WaterbodyOut(BaseModel):
slug: str
name_ru: str
unlock_level: int | None
fish_species_count: int | None
source_system: str | None
source_external_id: str | None
source_url: str | None
description: str | None
source_aliases: list[str] | None
source_fish_species: list[str] | None
source_image_urls: list[str] | None
source_point_urls: list[str] | None
source_checked_at: datetime | None
class BaitOut(BaseModel):
@@ -30,6 +40,75 @@ class BaitOut(BaseModel):
kind: str
class TackleItemRigOut(BaseModel):
id: UUID
name: str
source_system: str | None
source_checked_at: datetime | None
component_count: int
class TackleItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
category: str
subcategory: str | None
brand: str | None
family: str | None
unlock_level: int | None
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
related_rigs: list[TackleItemRigOut] = Field(default_factory=list)
class PaginatedTackleItemOut(BaseModel):
items: list[TackleItemOut]
total: int
limit: int
offset: int
class RigSummaryOut(BaseModel):
id: UUID
name: str
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
component_count: int
class PaginatedRigOut(BaseModel):
items: list[RigSummaryOut]
total: int
limit: int
offset: int
class RigComponentOut(BaseModel):
id: UUID
role: str
position: int
raw_value: str | None
tackle_item_id: UUID | None
class RigOut(BaseModel):
id: UUID
name: str
source_system: str | None
source_external_id: str | None
source_url: str | None
source_checked_at: datetime | None
missing_fields: list[str] = Field(default_factory=list)
components: list[RigComponentOut]
class ActivityOut(BaseModel):
spot_id: UUID
waterbody_slug: str
@@ -48,6 +127,9 @@ class ActivityOut(BaseModel):
confidence_score: int
explanation: str
sources: list[str]
coordinate_precision: str
coordinate_sources: list[str]
source_conflicts: list[str] = Field(default_factory=list)
class PaginatedActivityOut(BaseModel):
@@ -57,6 +139,19 @@ class PaginatedActivityOut(BaseModel):
offset: int
class TackleCombinationOut(BaseModel):
role: str
value: str
tackle_item_id: UUID | None = None
rig_id: UUID | None = None
catches: int
unique_players: int
last_seen_at: datetime
freshness_score: int = Field(ge=0, le=100)
status: str
explanation: str
class CatchOut(BaseModel):
id: UUID
fish: str
@@ -65,10 +160,23 @@ class CatchOut(BaseModel):
player_name: str | None
caught_at: datetime | None
reported_at: datetime
fishing_method: str | None
retrieve_method: str | None
retrieve_speed: int | None
source_system: str
source_url: str | None
tackle_components: list["CatchTackleComponentOut"]
class CatchTackleComponentOut(BaseModel):
id: UUID
role: str
position: int
raw_value: str
tackle_item_id: UUID | None
rig_id: UUID | None
source_system: str | None
source_url: str | None
class SpotOut(BaseModel):
@@ -82,6 +190,9 @@ class SpotOut(BaseModel):
catches_3d: int
catches_7d: int
top_baits: list[str]
coordinate_precision: str
coordinate_sources: list[str]
source_conflicts: list[str] = Field(default_factory=list)
class OfficialRecordOut(BaseModel):
@@ -215,6 +326,28 @@ class ModerationUpdate(BaseModel):
return value
class ModerationBulkItem(BaseModel):
id: UUID
expected_version: int = Field(ge=0)
class ModerationBulkUpdate(BaseModel):
status: str
reason: str | None = Field(default=None, max_length=1000)
items: list[ModerationBulkItem] = Field(min_length=1, max_length=50)
@field_validator("status")
@classmethod
def known_bulk_status(cls, value: str) -> str:
if value not in {"approved", "rejected", "pending"}:
raise ValueError("unknown moderation status")
return value
class ModerationBulkResult(BaseModel):
updated: int
class ExternalObservationOut(BaseModel):
id: UUID
source_system: str
@@ -270,6 +403,31 @@ class ExternalObservationDecision(BaseModel):
expected_version: int = Field(ge=0)
class ExternalObservationBulkItem(BaseModel):
id: UUID
expected_version: int = Field(ge=0)
class ExternalObservationBulkDecision(BaseModel):
reason: str = Field(min_length=1, max_length=1000)
items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50)
class ExternalObservationBulkAction(BaseModel):
items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50)
class ExternalObservationBulkMapping(BaseModel):
fish_slug: str
waterbody_slug: str
note: str | None = Field(default=None, max_length=1000)
items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50)
class ExternalObservationBulkResult(BaseModel):
updated: int
class ExternalObservationAction(BaseModel):
expected_version: int = Field(ge=0)
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy import delete
from sqlalchemy.orm import Session
from rf4_research.gear_components import GearComponentIdentity
from .models import CatchReport, CatchTackleComponent
def replace_tackle_components(
session: Session,
report: CatchReport,
components: Iterable[GearComponentIdentity],
*,
source_system: str | None,
source_url: str | None = None,
raw_payload: dict | None = None,
) -> None:
"""Replace the ordered evidence for a report while keeping imports idempotent."""
session.flush()
session.execute(
delete(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == report.id)
)
session.add_all(
CatchTackleComponent(
catch_report_id=report.id,
role=component.role,
position=component.position,
raw_value=component.raw_value,
source_system=source_system,
source_external_id=component.source_external_id,
source_url=source_url,
raw_payload=raw_payload,
)
for component in components
)
+1716 -1
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -131,6 +131,17 @@ def test_reports_without_coordinates_do_not_create_activity_group(db: Session) -
assert activity_rows(db, hours=72, now=NOW) == []
@pytest.mark.parametrize("precision", ["exact", "approximate", "area", "missing"])
def test_coordinate_precision_is_preserved_in_activity_evidence(db: Session, precision: str) -> None:
report = add_report(db, age_hours=1)
report.raw_payload = {"provenance": {"coordinate_precision": precision}}
db.commit()
row = activity_rows(db, hours=24, now=NOW)[0]
assert row.coordinate_precision == precision
def test_confidence_capped_at_50_with_single_player() -> None:
"""D06: One player cannot artificially inflate confidence above 50%."""
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+117
View File
@@ -0,0 +1,117 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Spot, Waterbody
from app.routers.analytics import tackle_combinations
from app.schemas import TackleCombinationOut
def test_tackle_recommendation_requires_samples_and_independent_players() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
spot = Spot(waterbody=waterbody, x=10, y=20)
db.add_all([waterbody, fish, spot])
db.flush()
for index, player in enumerate(("One", "One", "Two")):
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
caught_at=now - timedelta(hours=1), reported_at=now - timedelta(hours=1),
player_name=player, source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.append(CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"))
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
assert len(rows) == 1
assert (rows[0].status, rows[0].catches, rows[0].unique_players) == ("recommendation", 3, 2)
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=3)
assert rows[0].status == "insufficient_data"
engine.dispose()
def test_tackle_analytics_handles_empty_and_multicomponent_observations() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="empty-check", name_ru="Проверка", unlock_level=1)
fish = Fish(slug="perch", name_ru="Окунь", trophy_weight_g=5_000)
spot = Spot(waterbody=waterbody, x=1, y=2)
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=500,
caught_at=now, reported_at=now, player_name="Player",
source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.extend([
CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"),
CatchTackleComponent(role="rig", position=1, raw_value="Rig #1"),
CatchTackleComponent(role="lure", position=2, raw_value="Spinner #1"),
CatchTackleComponent(role="lure", position=3, raw_value=" "),
])
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="empty-check", fish="perch", method=None, hours=72, min_samples=1, min_players=1)
assert {(row.role, row.value, row.catches) for row in rows} == {
("lure", "Spinner #1", 1), ("rig", "Rig #1", 1),
}
assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == []
engine.dispose()
def test_tackle_analytics_exposes_decay_and_prefers_fresher_equal_samples() -> None:
now = datetime.now(timezone.utc)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
waterbody = Waterbody(slug="decay-check", name_ru="Свежесть", unlock_level=1)
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
spot = Spot(waterbody=waterbody, x=3, y=4)
db.add_all([waterbody, fish, spot])
db.flush()
for value, age in (("Fresh spinner", 1), ("Old spinner", 48)):
for index, player in enumerate(("One", "One", "Two")):
report = CatchReport(
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
caught_at=now - timedelta(hours=age), reported_at=now - timedelta(hours=age),
player_name=player, source_type=SourceType.user, source_confidence=80,
moderation_status=ModerationStatus.approved,
)
report.tackle_components.append(CatchTackleComponent(role="lure", position=index, raw_value=value))
db.add(report)
db.commit()
rows = tackle_combinations(db, waterbody="decay-check", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
assert [row.value for row in rows] == ["Fresh spinner", "Old spinner"]
assert rows[0].freshness_score > rows[1].freshness_score
assert rows[0].freshness_score <= 100
engine.dispose()
def test_tackle_freshness_score_contract_rejects_out_of_range_values() -> None:
base = {
"role": "lure", "value": "Spinner", "catches": 3, "unique_players": 2,
"last_seen_at": datetime.now(timezone.utc), "status": "recommendation", "explanation": "ok",
}
assert TackleCombinationOut(**base, freshness_score=0).freshness_score == 0
assert TackleCombinationOut(**base, freshness_score=100).freshness_score == 100
for value in (-1, 101):
try:
TackleCombinationOut(**base, freshness_score=value)
except ValueError:
continue
raise AssertionError(f"freshness score {value} was accepted")
+34 -2
View File
@@ -12,7 +12,7 @@ from app.database import Base, get_session
from app.community_importer import stage_observations
from app.importer import ImportAlreadyRunning
from app.main import app
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from app.models import Bait, BaitKind, CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from app.routers import admin as admin_router
@@ -55,6 +55,20 @@ def test_activity_filters_and_explains_score() -> None:
assert payload["items"][0]["unique_players"] == 3
assert "3 свежих улова" in payload["items"][0]["explanation"]
assert payload["items"][0]["sources"] == ["manual-import"]
assert payload["items"][0]["source_conflicts"] == []
def test_explicit_source_conflict_is_exposed_on_activity_and_spot() -> None:
with Session(engine) as db:
report = db.scalar(select(CatchReport).where(CatchReport.waterbody.has(slug="test-lake")))
assert report is not None
report.raw_payload = {"provenance": {"conflicts": ["координаты: 10:20 · 11:21"]}}
spot_id = report.spot_id
db.commit()
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=6").json()
assert activity["items"][0]["source_conflicts"] == ["координаты: 10:20 · 11:21"]
spot = client.get(f"/api/v1/spots/{spot_id}").json()
assert spot["source_conflicts"] == ["координаты: 10:20 · 11:21"]
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
@@ -82,9 +96,18 @@ def test_published_media_catalog_and_content_addressed_file() -> None:
assert image.status_code == 200
assert image.headers["content-type"].startswith("image/")
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
variant = client.get(item["variants"][0]["url"])
assert variant.status_code == 200
assert variant.headers["content-type"].startswith("image/")
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
def test_published_media_catalog_exposes_reviewed_media_role() -> None:
catalog = client.get("/api/v1/media/catalog?entity_type=fish")
assert catalog.status_code == 200
assert all("media_role" in item for item in catalog.json())
def test_review_queue_filters_before_pagination() -> None:
with Session(engine) as db:
stage_observations(db, [{
@@ -252,6 +275,12 @@ def test_spot_detail_and_catches() -> None:
assert catches.status_code == 200
assert len(catches.json()) == 3
assert catches.json()[0]["source_system"] == "manual-import"
assert catches.json()[0]["fishing_method"] == "spinning"
assert catches.json()[0]["tackle_components"] == []
fixture = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20")
assert fixture.status_code == 200
fixture_catches = client.get(f"/api/v1/spots/{fixture.json()['id']}/catches")
assert fixture_catches.json()[0]["fishing_method"] == "spinning"
resolved = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20")
assert resolved.status_code == 200
assert resolved.json()["id"] == spot_id
@@ -348,11 +377,14 @@ def test_records_pagination_returns_correct_total_and_offset() -> None:
def test_user_report_requires_moderation_before_activity() -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "rig_type": "Спиннинг", "player_name": "Reporter"})
assert created.status_code == 201
assert created.headers["Cache-Control"] == "no-store"
assert created.json()["moderation_status"] == "pending"
report_id = created.json()["id"]
with Session(engine) as db:
components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all()
assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")]
headers = {"Authorization": "Bearer change-me-in-production"}
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
assert pending.status_code == 200
+22 -1
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.catalog_audit import audit_catalog
from app.catalog_audit import audit_catalog, audit_waterbody_catalog
from app.database import Base
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
@@ -24,3 +24,24 @@ def test_catalog_audit_checks_the_whole_catalog() -> None:
assert result["reports"] == 1
assert result["invalid_coordinates"] == 1
assert result["failures"] == 1
def test_waterbody_catalog_audit_reports_snapshot_gaps_without_legacy_rows() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
now = datetime.now(timezone.utc)
db.add_all([
Waterbody(
slug="lake", name_ru="Озеро", source_system="rf4db",
source_external_id="level_001_lake", source_url="https://rf4db.com/ru/maps/level_001_lake",
source_checked_at=now,
),
Waterbody(slug="legacy", name_ru="Старое озеро"),
])
db.commit()
result = audit_waterbody_catalog(db, {"level_001_lake", "level_002_river"})
assert result["expected"] == 2
assert result["observed"] == 1
assert result["missing_source_external_ids"] == ["level_002_river"]
assert result["failures"] == 1
@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Waterbody
def test_catch_keeps_ordered_unresolved_gear_evidence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
fish = Fish(slug="pike", name_ru="Щука")
waterbody = Waterbody(slug="lake", name_ru="Озеро")
report = CatchReport(
fish=fish, waterbody=waterbody, weight_g=1000,
reported_at=datetime(2026, 9, 20, tzinfo=timezone.utc), source_type=SourceType.manual_import,
source_confidence=50, moderation_status=ModerationStatus.pending,
)
report.tackle_components.extend([
CatchTackleComponent(role="lure", position=0, raw_value="Spiker #2"),
CatchTackleComponent(role="rig", position=1, raw_value="Method Popup"),
])
session.add(report)
session.commit()
saved = session.get(CatchReport, report.id)
assert saved is not None
assert [(row.position, row.role, row.raw_value) for row in saved.tackle_components] == [
(0, "lure", "Spiker #2"), (1, "rig", "Method Popup"),
]
+105 -4
View File
@@ -7,11 +7,11 @@ import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations
from app.community_importer import CommunityImportError, stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
from app.source_lifecycle import record_scheduled_source_check, record_source_check
from app.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from app.models import CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
@@ -42,6 +42,20 @@ def record(source: str = "rf4db", external_id: str = "catch-1") -> dict[str, obj
}
def waterbody_row(**overrides: object) -> dict[str, object]:
row: dict[str, object] = {
"source_system": "rf4db",
"source_external_id": "level_001_mosquito",
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
"name": "оз. Комариное",
"unlock_level": 1,
"unlock_label": "1",
"fish_species_count": 20,
}
row.update(overrides)
return row
@pytest.fixture
def db() -> Session:
engine = create_engine("sqlite://")
@@ -69,6 +83,77 @@ def test_staging_is_idempotent_and_preserves_first_seen(db: Session) -> None:
assert source is not None and source.enabled is True
def test_waterbody_catalog_upsert_is_idempotent_and_non_destructive(db: Session) -> None:
first = datetime(2026, 9, 16, 10, tzinfo=timezone.utc)
assert upsert_waterbody_catalog(db, [waterbody_row()], fetched_at=first) == (1, 0)
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None
assert item.slug == "оз-комариное"
assert item.source_checked_at.replace(tzinfo=timezone.utc) == first
assert item.fish_species_count == 20
assert upsert_waterbody_catalog(db, [waterbody_row(name="Озеро Комариное", unlock_level=2)], fetched_at=first) == (0, 1)
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None
assert (item.name_ru, item.unlock_level, item.fish_species_count) == ("Озеро Комариное", 2, 20)
assert db.scalar(select(Waterbody).where(Waterbody.name_ru == "оз. Комариное")) is None
def test_waterbody_catalog_rejects_untrusted_source(db: Session) -> None:
with pytest.raises(CommunityImportError, match="source_url"):
upsert_waterbody_catalog(db, [waterbody_row(source_url="https://example.test/map")])
def test_waterbody_detail_updates_only_imported_identity_without_media_roles(db: Session) -> None:
upsert_waterbody_catalog(db, [waterbody_row()])
assert update_waterbody_detail(db, {
"source_system": "rf4db",
"source_external_id": "level_001_mosquito",
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
"name": "оз. Комариное",
"description": "Каменистые берега.",
"aliases": ["Комариное", "Комариное"],
"fish_species": ["Щука", "Окунь"],
"image_urls": ["https://oss.rf4db.com/map.webp"],
"point_urls": ["https://rf4db.com/ru/maps/level_001_mosquito/spots/12-34"],
}) is True
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None
assert item.source_aliases == ["Комариное"]
assert item.source_fish_species == ["Щука", "Окунь"]
assert item.source_image_urls == ["https://oss.rf4db.com/map.webp"]
def test_waterbody_detail_accepts_authorized_download_subdomain(db: Session) -> None:
upsert_waterbody_catalog(db, [waterbody_row()])
assert update_waterbody_detail(db, {
"source_system": "rf4db",
"source_external_id": "level_001_mosquito",
"source_url": "https://download.rf4db.com/ru/maps/level_001_mosquito",
"name": "оз. Комариное",
"description": None,
"aliases": [],
"fish_species": ["Щука"],
"image_urls": [],
"point_urls": [],
}) is True
def test_waterbody_detail_batch_validates_before_writing(db: Session) -> None:
upsert_waterbody_catalog(db, [waterbody_row()])
valid = {
"source_system": "rf4db", "source_external_id": "level_001_mosquito",
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito", "name": "оз. Комариное",
"description": "Описание", "aliases": [], "fish_species": ["Щука"],
"image_urls": [], "point_urls": [],
}
invalid = valid | {"source_external_id": "unknown", "source_url": "https://example.test/map"}
with pytest.raises(CommunityImportError, match="source_url"):
update_waterbody_details(db, [valid, invalid])
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
assert item is not None and item.description is None
def test_external_ids_are_isolated_by_source(db: Session) -> None:
created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")])
@@ -104,14 +189,28 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) ->
assert item.catch_report is not None
assert item.catch_report.fish_id == fish.id
assert item.catch_report.waterbody_id == waterbody.id
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1)
db.refresh(item)
assert item.status == "published"
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
def test_observation_preserves_coordinate_text_and_precision(db: Session) -> None:
stage_observations(db, [record() | {
"source_external_id": "coordinate-area",
"x": None, "y": None, "coordinate_raw": "северная бухта",
"coordinate_precision": "area", "weight_g": None,
}])
item = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "coordinate-area"))
assert item is not None
assert (item.coordinate_raw, item.coordinate_precision, item.x, item.y) == ("северная бухта", "area", None, None)
def test_coordinate_precision_rejects_unknown_value(db: Session) -> None:
with pytest.raises(CommunityImportError, match="invalid coordinate_precision"):
stage_observations(db, [record() | {"coordinate_precision": "guess"}])
def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None:
fish = Fish(slug="pike", name_ru="Щука")
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
@@ -140,6 +239,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
assert updated.id == report_id
assert updated.weight_g == 6000
assert updated.moderation_status.value == "approved"
components = db.scalars(select(CatchTackleComponent).order_by(CatchTackleComponent.position)).all()
assert [(component.position, component.raw_value) for component in components] == [(0, "Приманка")]
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
+2 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.database import Base
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
from app.models import CatchReport, CatchTackleComponent, ImportStatus, OfficialRecordImport, SourceType
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
@@ -50,6 +50,7 @@ def test_parser_and_import_are_idempotent() -> None:
# A12: Second import of identical data creates no events (no fields changed)
assert (second.rows_created, second.rows_updated) == (0, 0)
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
assert db.scalar(select(func.count()).select_from(CatchTackleComponent)) == 2
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
+20
View File
@@ -0,0 +1,20 @@
import json
from app import media_catalog
def test_public_media_catalog_rejects_unknown_and_cross_entity_roles(tmp_path, monkeypatch) -> None:
manifest = {
"assets": [
{"status": "approved", "sha256": "a" * 64, "local_path": "water.webp", "entity_type": "waterbody", "entity_key": "kuori", "media_role": "waterbody_map"},
{"status": "approved", "sha256": "b" * 64, "local_path": "wrong.webp", "entity_type": "waterbody", "entity_key": "kuori", "media_role": "tackle_card"},
{"status": "approved", "sha256": "c" * 64, "local_path": "unknown.webp", "entity_type": "tackle", "entity_key": "spiker", "media_role": "future_role"},
{"status": "approved", "sha256": "d" * 64, "local_path": "tackle.webp", "entity_type": "tackle", "entity_key": "spiker", "media_role": "tackle_card"},
],
}
(tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
monkeypatch.setattr(media_catalog, "MEDIA_ROOT", tmp_path)
rows = media_catalog.published_assets()
assert [row["id"] for row in rows] == ["d" * 64, "a" * 64]
+32
View File
@@ -0,0 +1,32 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import Rig, RigComponent, TackleItem
from app.routers.catalog import rig_detail, tackle_item, tackle_items
def test_tackle_catalog_filters_details_and_missing_fields() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
item = TackleItem(
name="API тестовая блесна", normalized_name="api тестовая блесна",
category="lure", subcategory="spinner", brand="RF4", family=None,
unlock_level=0, source_system="fixture", source_external_id="api-lure-1",
source_url="https://example.test/lure/api-lure-1",
)
rig = Rig(name="API тестовый монтаж", normalized_name="api тестовый монтаж", source_system="fixture")
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value=item.name))
db.add(rig)
db.commit()
page = tackle_items(db, category="lure", brand="RF4", family=None, unlock_level=None, limit=10, offset=0)
assert page.total == 1
assert page.items[0].missing_fields == ["family", "source_checked_at"]
assert tackle_item(item.id, db).name == item.name
details = rig_detail(rig.id, db)
assert details.components[0].raw_value == item.name
assert details.missing_fields == ["source_url", "source_checked_at"]
engine.dispose()
+35
View File
@@ -0,0 +1,35 @@
import uuid
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import Rig, RigComponent, TackleItem
def test_tackle_item_and_rig_components_keep_provenance_and_legacy_independence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
item_id = uuid.uuid4()
rig_id = uuid.uuid4()
with Session(engine) as session:
item = TackleItem(
id=item_id, name="Spiker #2", normalized_name="spiker #2",
category="lure", subcategory="spinner", brand="RF4", family="spoon",
unlock_level=0, source_system="rf4db", source_external_id="spiker-2",
source_url="https://rf4db.com/ru/wiki/lures/spiker-2",
raw_payload={"weight": {"state": "value", "value": 0}},
)
rig = Rig(
id=rig_id, name="Method Popup", normalized_name="method popup",
source_system="rf4db", source_external_id="method-popup",
)
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value="Spiker #2"))
session.add(rig)
session.commit()
saved = session.get(TackleItem, item_id)
assert saved is not None
assert saved.unlock_level == 0
assert saved.raw_payload == {"weight": {"state": "value", "value": 0}}
assert saved.rig_components[0].rig_id == rig_id
+8 -6
View File
@@ -1,13 +1,14 @@
---
import type { Activity } from "../lib/api";
import { activityLevel, ago, kg, plural, spotPath } from "../lib/api";
import { activityLevel, ago, coordinatePrecisionLabel, kg, spotPath } from "../lib/api";
import FishingIcon from "./FishingIcon.astro";
import DataPassport from "./DataPassport.astro";
import FishSilhouette from "./FishSilhouette.astro";
import TackleGlyph from "./TackleGlyph.astro";
const { item } = Astro.props as { item: Activity };
const { item, periodLabel = null } = Astro.props as { item: Activity; periodLabel?: string | null };
const level = activityLevel(item.activity_score);
const limited = item.catches < 3;
const precision = coordinatePrecisionLabel(item.coordinate_precision);
---
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
@@ -15,10 +16,11 @@ const limited = item.catches < 3;
<div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div>
<h3>{item.fish}</h3>
<FishSilhouette name={item.fish}/>
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y} · {precision}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
<p class="data-note">{item.explanation}</p>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/>
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} periodLabel={periodLabel} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} coordinateSources={item.coordinate_sources} sourceConflicts={item.source_conflicts} status={limited ? "insufficient" : "verified"}/>
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>{limited ? "Нужно ещё подтверждений" : item.best_bait ? "Работает сейчас" : "Наживка не указана"}</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
<span class="card-cta">Открыть точку <span aria-hidden="true">→</span></span>
</div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
<div class="spot-stats"><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{kg(item.max_weight_g)}</strong><span>макс. вес</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
</a>
@@ -0,0 +1,26 @@
---
import DataPassport from "./DataPassport.astro";
import type { Activity } from "../lib/api";
const { items, totalPoints, periodLabel } = Astro.props as { items: Activity[]; totalPoints: number; periodLabel: string };
const sources = [...new Set(items.flatMap(item => item.sources))];
const conflicts = [...new Set(items.flatMap(item => item.source_conflicts))];
const observations = items.reduce((sum, item) => sum + item.catches, 0);
const confidence = items.length ? Math.round(items.reduce((sum, item) => sum + item.confidence_score, 0) / items.length) : null;
const observedAt = items.map(item => item.last_confirmed_at).filter(Boolean).sort().at(-1) ?? null;
const status = observations < 3 ? "insufficient" : "verified";
---
<section class="activity-evidence content-grid" aria-labelledby="activity-evidence-title">
<div>
<span class="overline">Доверие к выборке</span>
<h2 id="activity-evidence-title">Паспорт активности</h2>
<p>{totalPoints} {totalPoints === 1 ? "точка" : totalPoints < 5 ? "точки" : "точек"} за {periodLabel}. Сводка построена по подтверждённым карточкам этого списка.</p>
</div>
<DataPassport sources={sources} observedAt={observedAt} periodLabel={periodLabel} confidence={confidence} sampleSize={observations} sourceConflicts={conflicts} status={status} />
</section>
<style>
.activity-evidence { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1.1fr); gap: 24px; align-items: start; margin-block: 26px; }
.activity-evidence h2 { margin: 6px 0 8px; }
.activity-evidence p { max-width: 470px; margin: 0; color: var(--text-muted); line-height: 1.6; }
@media (max-width: 720px) { .activity-evidence { grid-template-columns: 1fr; } }
</style>
+46
View File
@@ -0,0 +1,46 @@
---
import { spotPath, type Activity } from "../lib/api";
const { items } = Astro.props as { items: Activity[] };
const xValues = items.map((item) => item.x);
const yValues = items.map((item) => item.y);
const minX = Math.min(...xValues, 0);
const maxX = Math.max(...xValues, 1);
const minY = Math.min(...yValues, 0);
const maxY = Math.max(...yValues, 1);
const xRange = Math.max(1, maxX - minX);
const yRange = Math.max(1, maxY - minY);
const column = (value: number) => Math.min(20, Math.max(1, Math.round(((value - minX) / xRange) * 18) + 1));
const row = (value: number) => Math.min(20, Math.max(1, Math.round((1 - (value - minY) / yRange) * 18) + 1));
---
<section class="activity-map" aria-label="Схема координат горячих точек">
<header class="activity-map__header">
<div><span class="overline">Режим схемы</span><h3>Горячие точки на координатной сетке</h3></div>
<p>Показаны подтверждённые координаты текущей выборки. Это схема `x:y`, а не географическая карта водоёма.</p>
</header>
<div class="activity-map__plot" role="group" aria-label={`Координатная схема, точек: ${items.length}`}>
<span class="activity-map__axis activity-map__axis--x" aria-hidden="true">x →</span>
<span class="activity-map__axis activity-map__axis--y" aria-hidden="true">y ↑</span>
{items.map((item) => <a class:list={["activity-map__point", `activity-map__point--col-${column(item.x)}`, `activity-map__point--row-${row(item.y)}`]} href={spotPath(item)} aria-label={`${item.waterbody}, ${item.x}:${item.y}, ${item.fish}`}><span>{item.x}:{item.y}</span><strong>{item.fish}</strong></a>)}
</div>
</section>
<style>
.activity-map { display: grid; gap: 18px; }
.activity-map__header { display: flex; justify-content: space-between; gap: 22px; align-items: end; }
.activity-map__header h3 { margin: 6px 0 0; font: 400 27px Georgia, serif; letter-spacing: -.03em; }
.activity-map__header p { max-width: 330px; margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; }
.activity-map__plot { position: relative; display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); grid-template-rows: repeat(20, minmax(0, 1fr)); min-height: 470px; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: linear-gradient(90deg, color-mix(in srgb, var(--accent-muted) 8%, transparent) 1px, transparent 1px), linear-gradient(color-mix(in srgb, var(--accent-muted) 8%, transparent) 1px, transparent 1px), var(--surface-soft); background-size: 10% 10%; }
.activity-map__plot::after { content: ""; position: absolute; inset: 8%; border: 1px dashed color-mix(in srgb, var(--accent-muted) 40%, transparent); border-radius: 12px; pointer-events: none; }
.activity-map__axis { position: absolute; z-index: 1; color: var(--text-subtle); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.activity-map__axis--x { right: 5%; bottom: 3%; }
.activity-map__axis--y { left: 2%; top: 4%; writing-mode: vertical-rl; transform: rotate(180deg); }
.activity-map__point { position: relative; z-index: 2; display: grid; align-self: center; justify-self: center; gap: 3px; min-width: 74px; margin: 5px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); border-radius: 12px; background: var(--surface); color: var(--text); text-decoration: none; box-shadow: 0 8px 18px color-mix(in srgb, var(--deep) 11%, transparent); }
.activity-map__point::before { content: ""; position: absolute; left: 50%; bottom: -6px; width: 10px; height: 10px; border-right: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); border-bottom: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); background: var(--surface); transform: translateX(-50%) rotate(45deg); }
.activity-map__point:hover, .activity-map__point:focus-visible { border-color: var(--focus); transform: scale(1.03); }
.activity-map__point span { color: var(--text-muted); font-size: 10px; font-weight: 750; }
.activity-map__point strong { max-width: 130px; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
:global(.activity-map__point--col-1) { grid-column: 1; } :global(.activity-map__point--col-2) { grid-column: 2; } :global(.activity-map__point--col-3) { grid-column: 3; } :global(.activity-map__point--col-4) { grid-column: 4; } :global(.activity-map__point--col-5) { grid-column: 5; } :global(.activity-map__point--col-6) { grid-column: 6; } :global(.activity-map__point--col-7) { grid-column: 7; } :global(.activity-map__point--col-8) { grid-column: 8; } :global(.activity-map__point--col-9) { grid-column: 9; } :global(.activity-map__point--col-10) { grid-column: 10; } :global(.activity-map__point--col-11) { grid-column: 11; } :global(.activity-map__point--col-12) { grid-column: 12; } :global(.activity-map__point--col-13) { grid-column: 13; } :global(.activity-map__point--col-14) { grid-column: 14; } :global(.activity-map__point--col-15) { grid-column: 15; } :global(.activity-map__point--col-16) { grid-column: 16; } :global(.activity-map__point--col-17) { grid-column: 17; } :global(.activity-map__point--col-18) { grid-column: 18; } :global(.activity-map__point--col-19) { grid-column: 19; } :global(.activity-map__point--col-20) { grid-column: 20; }
:global(.activity-map__point--row-1) { grid-row: 1; } :global(.activity-map__point--row-2) { grid-row: 2; } :global(.activity-map__point--row-3) { grid-row: 3; } :global(.activity-map__point--row-4) { grid-row: 4; } :global(.activity-map__point--row-5) { grid-row: 5; } :global(.activity-map__point--row-6) { grid-row: 6; } :global(.activity-map__point--row-7) { grid-row: 7; } :global(.activity-map__point--row-8) { grid-row: 8; } :global(.activity-map__point--row-9) { grid-row: 9; } :global(.activity-map__point--row-10) { grid-row: 10; } :global(.activity-map__point--row-11) { grid-row: 11; } :global(.activity-map__point--row-12) { grid-row: 12; } :global(.activity-map__point--row-13) { grid-row: 13; } :global(.activity-map__point--row-14) { grid-row: 14; } :global(.activity-map__point--row-15) { grid-row: 15; } :global(.activity-map__point--row-16) { grid-row: 16; } :global(.activity-map__point--row-17) { grid-row: 17; } :global(.activity-map__point--row-18) { grid-row: 18; } :global(.activity-map__point--row-19) { grid-row: 19; } :global(.activity-map__point--row-20) { grid-row: 20; }
@media (max-width: 720px) { .activity-map__header { display: grid; gap: 8px; align-items: start; } .activity-map__plot { min-height: 390px; } .activity-map__point { min-width: 62px; padding: 7px 8px; } .activity-map__point strong { max-width: 90px; } }
</style>
@@ -0,0 +1,8 @@
---
const { hours = "72" } = Astro.props as { hours?: string };
const options = [["24", "24 часа"], ["72", "72 часа"], ["168", "7 дней"]] as const;
---
<form class="record-filters activity-period-filter" method="get" aria-label="Период активности">
<label>Период активности<select name="hours">{options.map(([value, label]) => <option value={value} selected={hours === value}>{label}</option>)}</select></label>
<button data-action="secondary">Обновить</button>
</form>
+4 -2
View File
@@ -3,16 +3,18 @@ import SourceBadge from "./SourceBadge.astro";
import { ago, kg, type Catch } from "../lib/api";
import TackleGlyph from "./TackleGlyph.astro";
const { catches } = Astro.props as { catches: Catch[] };
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
---
<div class="catch-list">
{catches.map(item => {
const timestamp = item.caught_at ?? item.reported_at;
return <article>
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span><SourceBadge source={item.source_system} href={item.source_url}/></div>
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span>{item.fishing_method && <span>Метод: {methodLabels[item.fishing_method] ?? item.fishing_method}</span>}{item.retrieve_method && <span>Проводка: {item.retrieve_method}{item.retrieve_speed != null ? ` · скорость ${item.retrieve_speed}` : ""}</span>}{item.tackle_components?.length > 0 && <details class="catch-components"><summary>Комплект снастей ({item.tackle_components.length})</summary><ul>{item.tackle_components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.rig_id ? <a href={`/tackle/rigs/${component.rig_id}`}>{component.raw_value}</a> : component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value}</a> : <span>{component.raw_value}</span>}</li>)}</ul></details>}<SourceBadge source={item.source_system} href={item.source_url}/></div>
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
</article>;
})}
</div>
<style>
time{margin-top:7px;color:var(--text-secondary);font-size:10px;font-weight:750}
time{margin-top:7px;color:var(--text-secondary);font-size:10px;font-weight:750}.catch-components{margin-top:8px}.catch-components summary{cursor:pointer;color:var(--text-secondary);font-size:11px;font-weight:750}.catch-components ul{display:grid;gap:4px;margin:8px 0 0;padding:0;list-style:none;color:var(--text-secondary);font-size:11px}.catch-components li{display:flex;gap:6px;flex-wrap:wrap}.catch-components li span:first-child{color:var(--text-subtle)}.catch-components a{color:inherit;text-underline-offset:3px}
</style>
+11 -6
View File
@@ -1,13 +1,18 @@
---
import SourceBadge from "./SourceBadge.astro";
import { ago } from "../lib/api";
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness?: number | null; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" };
const { sources, sourceUrl, observedAt, completeness = null, confidence = null, status = "verified" } = Astro.props;
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" };
import { ago, coordinatePrecisionLabel, freshnessStatus, passportDisplayStatus } from "../lib/api";
type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; coordinateSources?: string[]; sourceConflicts?: string[]; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" };
const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, coordinateSources = [], sourceConflicts = [], status = "verified" } = Astro.props as Props;
const freshness = freshnessStatus(observedAt);
const displayStatus = passportDisplayStatus(status, freshness, sourceConflicts.length > 0);
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" };
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
---
<section class="data-passport" aria-label="Паспорт данных">
<header><span>Паспорт данных</span><strong data-passport-status={status}>{statusLabels[status]}</strong></header>
<header><span>Паспорт данных</span><strong data-passport-status={displayStatus}>{statusLabels[displayStatus]}</strong></header>
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
<dl><div><dt>Свежесть</dt><dd>{ago(observedAt)}</dd></div><div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div></dl>
{sourceConflicts.length > 0 && <p class="data-passport__conflict"><strong>Источники расходятся:</strong> {sourceConflicts.join(" · ")}</p>}
{coordinateSources.length > 0 && <details class="data-passport__coordinate-sources"><summary>Источники координат</summary><div>{coordinateSources.map(source => <SourceBadge source={source}/>)}</div></details>}
<dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</dd></div>}<div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{coordinatePrecisionLabel(coordinatePrecision)}</dd></div>}</dl>
{sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>}
</section>
+9 -2
View File
@@ -1,9 +1,16 @@
---
import SourceBadge from "./SourceBadge.astro";
import type { MediaAsset } from "../lib/api";
import { mediaRoleLabel } from "../lib/media";
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
---
<figure class:list={["entity-media", { "entity-media--compact": compact }]}>
<span class="entity-media__frame"><img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" /></span>
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption>
<span class="entity-media__frame"><picture>
{(["avif", "webp"] as const).map(format => {
const variants = (asset.variants ?? []).filter(item => item.format === format);
return variants.length ? <source type={`image/${format}`} srcset={variants.map(item => `${item.url} ${item.width}w`).join(", ")} sizes={compact ? "180px" : "(max-width: 720px) 100vw, 720px"} /> : null;
})}
<img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" />
</picture></span>
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span>{asset.media_role && <small data-media-role={asset.media_role}>{mediaRoleLabel(asset.media_role)}</small>}</figcaption>
</figure>
+27
View File
@@ -0,0 +1,27 @@
---
import { pageHref, pageWindow } from "../lib/pagination";
interface Props {
path: string;
params: URLSearchParams;
total: number;
limit: number;
offset: number;
anchor?: string;
itemLabel?: string;
}
const { path, params, total, limit, offset, anchor = "", itemLabel = "записей" } = Astro.props;
const state = pageWindow(total, limit, offset);
---
{total > limit && <nav class="pagination" aria-label="Пагинация">
<span>{state.start}{state.end} из {total} {itemLabel} · страница {state.page} из {state.pages}</span>
<div>
{state.previousOffset !== null
? <a data-action="secondary" rel="prev" href={pageHref(path, params, state.previousOffset, anchor)}>← Предыдущая</a>
: <span class="pagination__disabled" aria-disabled="true">← Предыдущая</span>}
{state.nextOffset !== null
? <a data-action="secondary" rel="next" href={pageHref(path, params, state.nextOffset, anchor)}>Следующая →</a>
: <span class="pagination__disabled" aria-disabled="true">Следующая →</span>}
</div>
</nav>}
+3 -1
View File
@@ -1,4 +1,5 @@
---
import { safeHttpUrl } from "../lib/urls";
const { source, href, tone = "source" } = Astro.props as { source: string; href?: string | null; tone?: "source" | "incomplete" | "verified" };
const labels: Record<string, string> = {
"rf4-official": "RF4 · официальный",
@@ -12,5 +13,6 @@ const labels: Record<string, string> = {
};
const label = labels[source] ?? source;
const mark = source === "players" ? "♟" : source === "rf4-official" ? "★" : "↗";
const safeHref = safeHttpUrl(href);
---
{href ? <a class="source-chip" data-source={source} data-tone={tone} href={href} target="_blank" rel="noreferrer" title={`Открыть источник: ${label}`}><i>{mark}</i><span>{label}</span></a> : <span class="source-chip" data-source={source} data-tone={tone}><i>{mark}</i><span>{label}</span></span>}
{safeHref ? <a class="source-chip" data-source={source} data-tone={tone} href={safeHref} target="_blank" rel="noreferrer" title={`Открыть источник: ${label}`}><i>{mark}</i><span>{label}</span></a> : <span class="source-chip" data-source={source} data-tone={tone}><i>{mark}</i><span>{label}</span></span>}
+3 -1
View File
@@ -14,6 +14,8 @@ import "../styles/signal-pagination.css";
import "../styles/pagination.css";
import "../styles/dashboard-polish.css";
import "../styles/loading-states.css";
import "../styles/query-context.css";
import "../styles/plan.css";
import "../styles/theme.css";
import "../styles/media-catalog.css";
import FishingIcon from "../components/FishingIcon.astro";
@@ -121,7 +123,7 @@ Astro.response.headers.set("Content-Security-Policy", [
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
<header class="topbar">
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/media")}} aria-current={path.startsWith("/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path === "/plan"}} aria-current={path === "/plan" ? "page" : undefined} href="/plan"><FishingIcon name="pin"/> <span>Мой план</span></a><a class:list={{active:path.startsWith("/media") || path.startsWith("/admin/media")}} aria-current={path.startsWith("/media") || path.startsWith("/admin/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
<div class="header-tools">
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
+16 -5
View File
@@ -4,6 +4,7 @@ export type Activity = {
unique_players: number; average_weight_g: number; max_weight_g: number;
last_confirmed_at: string; activity_score: number; confidence_score: number;
explanation: string; sources: string[];
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[];
};
export type PaginatedActivity = {
@@ -13,9 +14,17 @@ export type PaginatedActivity = {
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 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 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[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[] };
export type CatchTackleComponent = { id: string; role: string; position: number; raw_value: string; tackle_item_id: string | null; rig_id: string | null; source_system: string | null; source_url: string | null };
export type RigComponent = { id: string; role: string; position: number; raw_value: string | null; tackle_item_id: string | null };
export type Rig = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; components: RigComponent[] };
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; fishing_method: string | null; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null; tackle_components: CatchTackleComponent[] };
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; related_rigs: { id: string; name: string; source_system: string | null; source_checked_at: string | null; component_count: number }[] };
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
export type RigSummary = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; component_count: number };
export type PaginatedRigs = { items: RigSummary[]; total: number; limit: number; offset: number };
export type TackleCombination = { role: string; value: string; tackle_item_id: string | null; rig_id: string | null; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: string };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
export type PaginatedOfficialRecord = {
items: OfficialRecord[];
@@ -26,11 +35,13 @@ export type PaginatedOfficialRecord = {
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string };
export type MediaVariant = { role: "card" | "detail"; format: "webp" | "avif"; width: number; height: number; url: string };
export type MediaRole = "waterbody_cover" | "waterbody_map" | "waterbody_depth_map" | "waterbody_screenshot" | "tackle_card" | "tackle_detail" | "rig_diagram" | "tackle_screenshot";
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; media_role?: MediaRole | null; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string; variants?: MediaVariant[] };
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
export { activityLevel, ago, kg, plural } from "./presentation";
export { activityLevel, ago, coordinatePrecisionLabel, freshnessStatus, kg, passportDisplayStatus, plural, sourceStatusLabel } from "./presentation";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
+18 -4
View File
@@ -1,4 +1,17 @@
import type { MediaAsset } from "./api";
import type { MediaAsset, MediaRole } from "./api";
const mediaRoleLabels: Record<MediaRole, string> = {
waterbody_cover: "Заставка водоёма",
waterbody_map: "Карта водоёма",
waterbody_depth_map: "Карта глубин",
waterbody_screenshot: "Скриншот водоёма",
tackle_card: "Карточка снасти",
tackle_detail: "Деталь снасти",
rig_diagram: "Схема сборки",
tackle_screenshot: "Скриншот снасти",
};
export const mediaRoleLabel = (role: string): string => mediaRoleLabels[role as MediaRole] ?? role;
const tokens = (value: string) => value
.toLocaleLowerCase("ru")
@@ -9,13 +22,14 @@ const tokens = (value: string) => value
.split(/\s+/)
.filter(Boolean);
export const findMediaByLabel = (assets: MediaAsset[], label: string): MediaAsset | undefined => {
export const findMediaByLabel = (assets: MediaAsset[], label: string, allowedRoles?: readonly MediaRole[]): MediaAsset | undefined => {
const filtered = allowedRoles ? assets.filter((asset) => asset.media_role && allowedRoles.includes(asset.media_role)) : assets;
const wanted = tokens(label);
const exact = assets.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" "));
const exact = filtered.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" "));
if (exact.length === 1) return exact[0];
const wantedSet = new Set(wanted);
const candidates = assets.filter((asset) => {
const candidates = filtered.filter((asset) => {
const available = new Set(tokens(asset.label ?? ""));
return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token));
});
+31
View File
@@ -0,0 +1,31 @@
export type PageWindow = {
start: number;
end: number;
page: number;
pages: number;
previousOffset: number | null;
nextOffset: number | null;
};
export const pageWindow = (total: number, limit: number, offset: number): PageWindow => {
const safeTotal = Math.max(0, Math.trunc(total));
const safeLimit = Math.max(1, Math.trunc(limit));
const safeOffset = Math.max(0, Math.trunc(offset));
const end = Math.min(safeOffset + safeLimit, safeTotal);
return {
start: safeTotal > 0 && safeOffset < safeTotal ? safeOffset + 1 : 0,
end,
page: Math.floor(safeOffset / safeLimit) + 1,
pages: Math.max(1, Math.ceil(safeTotal / safeLimit)),
previousOffset: safeOffset > 0 ? Math.max(0, safeOffset - safeLimit) : null,
nextOffset: end < safeTotal ? safeOffset + safeLimit : null,
};
};
export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "") => {
const params = new URLSearchParams(search);
if (offset > 0) params.set("offset", String(offset));
else params.delete("offset");
const query = params.toString();
return `${path}${query ? `?${query}` : ""}${anchor}`;
};
+30
View File
@@ -20,3 +20,33 @@ export function ago(value: string) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
}
export function freshnessStatus(value: string | null | undefined, now = Date.now()): "fresh" | "stale" | "unknown" {
if (!value) return "unknown";
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) return "unknown";
return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh";
}
export type CoordinatePrecision = "exact" | "approximate" | "area" | "missing";
export function coordinatePrecisionLabel(value: CoordinatePrecision | string | null | undefined): string {
return ({ exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as Record<string, string>)[value ?? ""] ?? "не указаны";
}
export type PassportStatus = "verified" | "unverified" | "incomplete" | "insufficient" | "blocked";
export type PassportDisplayStatus = PassportStatus | "conflict" | "stale";
export function passportDisplayStatus(status: PassportStatus, freshness: "fresh" | "stale" | "unknown", hasConflicts: boolean): PassportDisplayStatus {
if (hasConflicts) return "conflict";
if (status === "verified" && freshness === "stale") return "stale";
return status;
}
export function sourceStatusLabel(status: string) {
const labels: Record<string, string> = {
healthy: "Актуален", stale: "Данные устарели", temporarily_limited: "Источник временно ограничен",
source_changed: "Источник изменился", waiting: "Ожидает запуска", disabled: "Выключен",
};
return labels[status] ?? "Состояние не определено";
}
+10
View File
@@ -0,0 +1,10 @@
export function safeHttpUrl(value: string | null | undefined): string | null {
if (!value) return null;
try {
const url = new URL(value);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || !url.hostname) return null;
return url.href;
} catch {
return null;
}
}
@@ -17,7 +17,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<label>Порядок<select name="order"><option value="risk">Сначала требуют внимания</option><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
<button data-action="primary" type="submit">Применить</button>
</form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><label><input type="checkbox" data-external-select-all> Выбрать все записи на странице</label><div data-external-bulk hidden><strong data-external-bulk-count>0 выбрано</strong><label>Рыба<select data-external-bulk-fish><option value="">Выберите…</option></select></label><label>Водоём<select data-external-bulk-waterbody><option value="">Выберите…</option></select></label><textarea data-external-bulk-reason rows="1" maxlength="1000" placeholder="Примечание или причина отклонения"></textarea><button data-action="secondary" type="button" data-external-bulk-map>Сопоставить выбранные</button><button data-action="primary" type="button" data-external-bulk-publish>Опубликовать выбранные</button><button data-action="danger" type="button" data-external-bulk-reject>Отклонить выбранные</button></div><div class="moderation-list" data-external-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden>
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
<span data-page-number aria-live="polite"></span>
@@ -40,17 +40,28 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
const bulk = document.querySelector<HTMLElement>("[data-external-bulk]");
const selectAll = document.querySelector<HTMLInputElement>("[data-external-select-all]");
let offset = 0;
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем внешние наблюдения</span>`;
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password && url.hostname ? esc(url.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } syncBulk(); pages?.setAttribute("hidden", ""); if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const syncBulk = () => { const inputs = [...(list?.querySelectorAll<HTMLInputElement>("[data-external-select]") ?? [])]; const selected = inputs.filter(input => input.checked).length; if (bulk) bulk.hidden = selected === 0; if (selectAll) { selectAll.checked = inputs.length > 0 && selected === inputs.length; selectAll.indeterminate = selected > 0 && selected < inputs.length; } const count = bulk?.querySelector<HTMLElement>("[data-external-bulk-count]"); if (count) count.textContent = `${selected} выбрано`; };
const hydrateBulk = () => { list?.querySelectorAll<HTMLElement>("[data-observation-id]").forEach(card => { if (card.querySelector("[data-external-select]")) return; const label = document.createElement("label"); label.innerHTML = `<input type="checkbox" data-external-select aria-label="Выбрать наблюдение">`; card.prepend(label); }); const fishSelect = bulk?.querySelector<HTMLSelectElement>("[data-external-bulk-fish]"); const waterSelect = bulk?.querySelector<HTMLSelectElement>("[data-external-bulk-waterbody]"); if (fishSelect && fishSelect.options.length === 1 && fishes.length) fishSelect.innerHTML = '<option value="">Выберите…</option>' + options(fishes); if (waterSelect && waterSelect.options.length === 1 && waters.length) waterSelect.innerHTML = '<option value="">Выберите…</option>' + options(waters); syncBulk(); };
const selectedCards = () => [...(list?.querySelectorAll<HTMLInputElement>("[data-external-select]:checked") ?? [])].map(input => input.closest<HTMLElement>("[data-observation-id]")).filter((card): card is HTMLElement => Boolean(card));
const setBulkBusy = (busy: boolean) => bulk?.querySelectorAll<HTMLButtonElement>("button").forEach(button => { button.disabled = busy; });
bulk?.querySelector<HTMLButtonElement>("[data-external-bulk-map]")?.addEventListener("click", async () => { const cards = selectedCards(); const fish_slug = bulk.querySelector<HTMLSelectElement>("[data-external-bulk-fish]")?.value; const waterbody_slug = bulk.querySelector<HTMLSelectElement>("[data-external-bulk-waterbody]")?.value; if (!cards.length) return; if (!fish_slug || !waterbody_slug) { fail("Выберите каноническую рыбу и водоём."); return; } setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-mapping`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({fish_slug,waterbody_slug,note:bulk.querySelector<HTMLTextAreaElement>("[data-external-bulk-reason]")?.value.trim() || null,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Сопоставлено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного сопоставления."); } finally { setBulkBusy(false); } });
bulk?.querySelector<HTMLButtonElement>("[data-external-bulk-publish]")?.addEventListener("click", async () => { const cards = selectedCards(); if (!cards.length) return; setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-publish`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Опубликовано наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетной публикации."); } finally { setBulkBusy(false); } });
bulk?.querySelector<HTMLButtonElement>("[data-external-bulk-reject]")?.addEventListener("click", async () => { const cards = selectedCards(); const reason = bulk.querySelector<HTMLTextAreaElement>("[data-external-bulk-reason]")?.value.trim(); if (!cards.length) return; if (!reason) { fail("Укажите причину отклонения."); return; } setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-reject`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({reason,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Отклонено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного отклонения."); } finally { setBulkBusy(false); } });
list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-external-select]")) syncBulk(); });
selectAll?.addEventListener("change", () => { list?.querySelectorAll<HTMLInputElement>("[data-external-select]").forEach(input => input.checked = selectAll.checked); syncBulk(); });
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, `Запрос завершился ошибкой ${response.status}.`)); } if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
async function loadQueue() {
@@ -68,6 +79,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}" data-version="${esc(row.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
hydrateBulk();
const payloadLabels: Record<string, string> = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"};
const missingLabels: Record<string, string> = {coordinates:"координаты",weight_g:"вес"};
pending.forEach((row: Record<string, unknown>, index: number) => {
+44 -1
View File
@@ -25,20 +25,32 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = "";
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
let importCooldownUntil = 0;
let importCooldownTimer: ReturnType<typeof setInterval> | undefined;
let importHistoryOffset = 5;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const syncImportButton = () => { const button = content?.querySelector<HTMLButtonElement>("[data-official-import]"); if (!button) return; const seconds = Math.max(0, Math.ceil((importCooldownUntil - Date.now()) / 1000)); button.disabled = seconds > 0; button.textContent = seconds > 0 ? `Повторить через ${seconds < 60 ? `${seconds} с` : `${Math.ceil(seconds / 60)} мин`}` : "Запустить импорт"; button.setAttribute("aria-disabled", String(seconds > 0)); };
const startImportCooldown = (seconds: number) => { if (!Number.isFinite(seconds) || seconds <= 0) return; importCooldownUntil = Math.max(importCooldownUntil, Date.now() + Math.ceil(seconds) * 1000); if (importCooldownTimer) clearInterval(importCooldownTimer); syncImportButton(); importCooldownTimer = setInterval(() => { syncImportButton(); if (Date.now() >= importCooldownUntil && importCooldownTimer) { clearInterval(importCooldownTimer); importCooldownTimer = undefined; } }, 1000); };
const importStatusLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
const importRow = (run: Record<string, unknown>) => { const state = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `<li><span>${esc(importStatusLabels[state] ?? state)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; };
const ensureImportMoreButton = () => { const section = content?.querySelector<HTMLElement>(".admin-dashboard-grid section:nth-child(2)"); const list = section?.querySelector<HTMLUListElement>("ul"); if (!section || !list || list.children.length < 5 || section.querySelector("[data-import-more]")) return; const button = document.createElement("button"); button.type = "button"; button.dataset.action = "quiet"; button.dataset.importMore = "true"; button.textContent = "Показать ещё импорты"; list.after(button); };
window.setInterval(ensureImportMoreButton, 1000);
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); }
async function loadDashboard() {
if (!content) return;
importHistoryOffset = 5;
error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; const success = source.last_success_at ? ` · успех ${new Date(String(source.last_success_at)).toLocaleString("ru-RU")}` : " · успешных запусков нет"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail + success)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
const savedCooldown = Math.max(0, ...(sources as Record<string, unknown>[]).map(source => Number(source.cooldown_seconds ?? 0)).filter(Number.isFinite));
if (savedCooldown > 0) startImportCooldown(savedCooldown);
const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; }).join("");
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
@@ -46,8 +58,39 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
}
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); ensureImportMoreButton(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
content?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-official-import]");
if (!button) return;
event.preventDefault(); event.stopImmediatePropagation();
if (Date.now() < importCooldownUntil) { syncImportButton(); return; }
button.disabled = true;
try {
const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`} });
if (!response.ok) {
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
if (response.status === 429 && retryAfter > 0) startImportCooldown(retryAfter);
if (adminEndsSession(response.status)) endSession();
throw new Error(response.status === 429 ? "Источник на cooldown. Повторный запуск временно заблокирован." : response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт."));
}
keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён.");
} catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
finally { syncImportButton(); }
}, true);
content?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-import-more]");
if (!button) return;
event.preventDefault(); event.stopImmediatePropagation(); button.disabled = true;
try {
const rows = await authorizedJson(`/api/v1/admin/imports?limit=5&offset=${importHistoryOffset}`) as Record<string, unknown>[];
const list = content?.querySelector<HTMLUListElement>(".admin-dashboard-grid section:nth-child(2) ul");
if (list) list.insertAdjacentHTML("beforeend", rows.map(importRow).join(""));
importHistoryOffset += rows.length;
if (rows.length < 5) button.remove(); else button.disabled = false;
keepSession();
} catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Не удалось загрузить историю импортов."); }
}, true);
content?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
button.disabled = true;
+11 -4
View File
@@ -10,12 +10,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
<p class="privacy">Публикация требует явной причины и атомарно сохраняет fallback для rollback. Токен хранится только в памяти страницы.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
<form class="admin-queue-filters" hidden><label>Поиск по подписи<input name="q" maxlength="100" placeholder="Например, щука или Вьюнок" /></label><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Роль<select name="media_role"><option value="">Все роли</option><option value="waterbody_cover">Заставка водоёма</option><option value="waterbody_map">Карта водоёма</option><option value="waterbody_depth_map">Карта глубин</option><option value="waterbody_screenshot">Скриншот водоёма</option><option value="tackle_card">Карточка снасти</option><option value="tackle_detail">Деталь снасти</option><option value="rig_diagram">Схема сборки</option><option value="tackle_screenshot">Скриншот снасти</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
<nav data-pages aria-label="Страницы медиатеки" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
</section>
<script>
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
import { mediaRoleLabel } from "../../lib/media";
const root = document.querySelector<HTMLElement>("[data-api-url]");
const login = document.querySelector<HTMLFormElement>(".admin-login");
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
@@ -29,9 +30,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const next = document.querySelector<HTMLButtonElement>("[data-next]");
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const entityFilter = filters?.querySelector<HTMLSelectElement>('[name="entity_type"]');
const roleFilter = filters?.querySelector<HTMLSelectElement>('[name="media_role"]');
const rolesByEntity: Record<string, Set<string>> = { waterbody: new Set(["waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot"]), tackle: new Set(["tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"]) };
const syncRoleFilter = () => { const allowed = rolesByEntity[entityFilter?.value ?? ""] ?? new Set<string>(); if (roleFilter) { [...roleFilter.options].forEach(option => { if (!option.value) return; option.hidden = !allowed.has(option.value); option.disabled = !allowed.has(option.value); }); if (roleFilter.value && !allowed.has(roleFilter.value)) roleFilter.value = ""; roleFilter.disabled = allowed.size === 0; } };
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return parsed.protocol === "http:" || parsed.protocol === "https:" ? esc(parsed.href) : ""; } catch { return ""; } };
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password && parsed.hostname ? esc(parsed.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
@@ -40,14 +45,14 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
async function load() {
if (!root || !list || !filters) return;
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["q", "entity_type", "media_role", "status"]) { const value = String(values.get(key) || "").trim(); if (value) params.set(key, value); }
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); }
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
if (!rows.length && offset > 0) { offset = 0; return load(); }
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const role = String(asset.media_role || ""); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"${role ? ` data-media-role="${esc(role)}"` : ""}><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span>${role ? `<small>${esc(mediaRoleLabel(role))}</small>` : ""}<p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
list.querySelectorAll<HTMLButtonElement>("[data-media-rollback]").forEach(button => button.addEventListener("click", async () => { const note = window.prompt("Причина отката:"); if (!note?.trim()) return; button.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/rollback`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({asset_url:button.dataset.mediaRollback, note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось выполнить откат.")); succeed("Fallback восстановлен."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось выполнить откат."); } finally { button.disabled = false; } }));
if (assets.some(asset => asset.status === "upgrade_stored")) { const publish = document.createElement("button"); publish.type = "button"; publish.dataset.action = "primary"; publish.textContent = "Опубликовать сохранённые замены"; publish.addEventListener("click", async () => { const note = window.prompt("Причина публикации замен:"); if (!note?.trim()) return; publish.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/publish`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось опубликовать замены.")); succeed("Замены опубликованы атомарно, fallback сохранены."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось опубликовать замены."); } finally { publish.disabled = false; } }); list.prepend(publish); }
}
@@ -56,5 +61,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
entityFilter?.addEventListener("change", syncRoleFilter);
syncRoleFilter();
</script>
</Layout>
+13 -3
View File
@@ -10,7 +10,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><label class="queue-select-all"><input type="checkbox" data-bulk-select-all> Выбрать все записи на странице</label><div class="moderation-bulk" data-bulk hidden><strong data-bulk-count>0 выбрано</strong><label>Причина отклонения<textarea data-bulk-reason rows="1" maxlength="1000"></textarea></label><button data-action="primary" type="button" data-bulk-approve>Одобрить выбранные</button><button data-action="danger" type="button" data-bulk-reject>Отклонить выбранные</button></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
</section>
@@ -28,17 +28,27 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
const bulk = document.querySelector<HTMLElement>("[data-bulk]");
const selectAll = document.querySelector<HTMLInputElement>("[data-bulk-select-all]");
let token = "";
let offset = 0;
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password && url.hostname ? esc(url.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } syncBulk(); if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const syncBulk = () => { const inputs = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]") ?? [])]; const cards = inputs.filter(input => input.checked); if (bulk) bulk.hidden = cards.length === 0; if (selectAll) { selectAll.checked = inputs.length > 0 && cards.length === inputs.length; selectAll.indeterminate = cards.length > 0 && cards.length < inputs.length; } const count = bulk?.querySelector<HTMLElement>("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; };
const hydrateBulk = () => { list?.querySelectorAll<HTMLElement>("[data-report-id]").forEach(card => { if (card.querySelector("[data-bulk-select]")) return; const label = document.createElement("label"); label.className = "moderation-select"; label.innerHTML = `<input type="checkbox" data-bulk-select aria-label="Выбрать улов ${card.querySelector("h2")?.textContent ?? ""}">`; card.prepend(label); }); syncBulk(); };
window.setInterval(hydrateBulk, 1000);
async function runBulk(statusValue: "approved" | "rejected") { const selected = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]:checked") ?? [])].map(input => input.closest<HTMLElement>("[data-report-id]")); const reason = bulk?.querySelector<HTMLTextAreaElement>("[data-bulk-reason]")?.value.trim() || null; if (!selected.length) return; if (statusValue === "rejected" && !reason) { fail("Укажите причину отклонения."); return; } const buttons = bulk?.querySelectorAll<HTMLButtonElement>("button") ?? []; buttons.forEach(button => button.disabled = true); try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/catch-reports/bulk`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:statusValue,reason,items:selected.map(card => ({id:card?.dataset.reportId,expected_version:Number(card?.dataset.version)}))})}); if (response.status === 409) { await loadQueue(); throw new Error("Часть выбранных записей уже изменена. Очередь обновлена."); } if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось применить пакетное решение.")); keepSession(); await loadQueue(); succeed(`Обработано записей: ${selected.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного решения."); } finally { buttons.forEach(button => button.disabled = false); syncBulk(); } }
bulk?.querySelector<HTMLButtonElement>("[data-bulk-approve]")?.addEventListener("click", () => runBulk("approved"));
bulk?.querySelector<HTMLButtonElement>("[data-bulk-reject]")?.addEventListener("click", () => runBulk("rejected"));
list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-bulk-select]")) syncBulk(); });
selectAll?.addEventListener("change", () => { list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]").forEach(input => input.checked = selectAll.checked); syncBulk(); });
async function loadQueue() {
if (!root || !list) return;
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
+23 -4
View File
@@ -1,20 +1,36 @@
---
import ActivityCard from "../../components/ActivityCard.astro";
import ActivityEvidenceSummary from "../../components/ActivityEvidenceSummary.astro";
import ActivityPeriodFilter from "../../components/ActivityPeriodFilter.astro";
import EntityMedia from "../../components/EntityMedia.astro";
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import Layout from "../../layouts/Layout.astro";
import Pagination from "../../components/Pagination.astro";
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
import { findMediaByLabel } from "../../lib/media";
const { slug } = Astro.params;
let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
const requestedHours = Astro.url.searchParams.get("hours") ?? "72";
const hours = ["24", "72", "168"].includes(requestedHours) ? requestedHours : "72";
const periodLabel = { "24": "24 часа", "72": "72 часа", "168": "7 дней" }[hours];
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const activityLimit = 24;
let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], totalItems = 0, unavailable = false;
try {
const [fishes, fishMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
media = fishMedia;
fish = fishes.find(item => item.slug === slug);
if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
if (fish) {
let paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
if (paginated.total > 0 && offset >= paginated.total) {
offset = Math.floor((paginated.total - 1) / activityLimit) * activityLimit;
paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
}
items = paginated.items; totalItems = paginated.total;
}
} catch { unavailable = true; }
if (unavailable) {
Astro.response.status = 503;
@@ -28,7 +44,10 @@ const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage
---
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
<PageHero eyebrow={`Свежие данные за ${periodLabel}`} title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${totalItems} ${plural(totalItems,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
{fish && <ActivityPeriodFilter hours={hours} />}
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${fish!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Визуальный справочник</span><h2>{fish!.name_ru}</h2><p>Изображение опубликовано с прямой ссылкой на источник. Оно помогает отличить вид, а актуальные точки ниже остаются отдельными наблюдениями игроков.</p></div></section>}
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
{fish && <ActivityEvidenceSummary items={items} totalPoints={totalItems} periodLabel={periodLabel} />}
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}?hours=${hours}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel={periodLabel}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
{fish && <Pagination path={`/fish/${fish.slug}`} params={new URLSearchParams({ hours })} total={totalItems} limit={activityLimit} offset={offset} itemLabel="точек" />}
</Layout>
+19 -5
View File
@@ -4,17 +4,31 @@ import FishSilhouette from "../../components/FishSilhouette.astro";
import EntityMedia from "../../components/EntityMedia.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro";
import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
import { findMediaByLabel } from "../../lib/media";
let fishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
try { [fishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]); } catch { unavailable = true; }
const params = Astro.url.searchParams;
const search = params.get("q")?.trim() ?? "";
const pageLimit = 48;
const requestedOffset = Number(params.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let fishes: DictionaryItem[] = [], allFishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
try {
[allFishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
const matchingFishes = search ? allFishes.filter(item => item.name_ru.toLocaleLowerCase("ru-RU").includes(search.toLocaleLowerCase("ru-RU"))) : allFishes;
if (matchingFishes.length > 0 && offset >= matchingFishes.length) offset = Math.floor((matchingFishes.length - 1) / pageLimit) * pageLimit;
fishes = matchingFishes.slice(offset, offset + pageLimit);
allFishes = matchingFishes;
} catch { unavailable = true; }
if (unavailable) {
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
---
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={fishes.length} />
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav>}
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={allFishes.length} />
<form class="record-filters content-grid" method="get" aria-label="Поиск рыбы"><label>Поиск по названию<input name="q" value={search} maxlength="100" placeholder="Например, щука" /></label><button data-action="primary">Найти</button>{search && <a data-action="quiet" href="/fish">Сбросить</a>}</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : fishes.length ? <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(offset + index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav> : <StatePanel title="Ничего не найдено" description="Попробуйте изменить запрос или открыть полный справочник рыбы." />}
{!unavailable && <Pagination path="/fish" params={params} total={allFishes.length} limit={pageLimit} offset={offset} itemLabel="видов" />}
</Layout>
+17 -5
View File
@@ -1,11 +1,13 @@
---
import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro";
import ActivityMap from "../components/ActivityMap.astro";
import FishingIcon from "../components/FishingIcon.astro";
import SourceBadge from "../components/SourceBadge.astro";
import TackleGlyph from "../components/TackleGlyph.astro";
import SignalFeed from "../components/SignalFeed.astro";
import StatePanel from "../components/StatePanel.astro";
import Pagination from "../components/Pagination.astro";
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
const params = Astro.url.searchParams;
@@ -13,8 +15,10 @@ const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const sort = params.get("sort") ?? "activity";
const view = params.get("view") === "map" ? "map" : "list";
const activityLimit = 5;
const requestedOffset = Number(params.get("offset") ?? 0);
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const requestedSignalLimit = Number(params.get("signals") ?? 12);
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
@@ -38,11 +42,16 @@ if (!signalsUnavailable) {
signals = signalRows.slice(0, signalLimit);
}
if (!filterError) {
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) });
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: String(activityLimit), offset: String(offset) });
try {
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
items = paginated.items;
totalItems = paginated.total;
if (totalItems > 0 && offset >= totalItems) {
offset = Math.floor((totalItems - 1) / activityLimit) * activityLimit;
query.set("offset", String(offset));
items = (await api<PaginatedActivity>(`/api/v1/activity?${query}`)).items;
}
} catch { activityUnavailable = true; }
}
const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable;
@@ -62,9 +71,11 @@ const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Люб
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
const queryTitle = `${selectedFish} · ${selectedWaterbody}`;
const moreSignalParams = new URLSearchParams(params);
moreSignalParams.set("signals", String(Math.min(48, signalLimit + 12)));
const moreSignalsHref = `/?${moreSignalParams.toString()}#signals-title`;
const viewHref = (next: "list" | "map") => { const query = new URLSearchParams(params); query.set("view", next); query.delete("offset"); return `/?${query.toString()}#results`; };
const datasetJsonLd = {
"@context": "https://schema.org", "@type": "Dataset",
name: "RF4 Spotter — наблюдения об активности рыбы",
@@ -85,10 +96,11 @@ const datasetJsonLd = {
</div></details>
<button data-action="primary">⌕ Найти клёв</button>
</form></section>
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
<section class="query-summary content-grid" aria-label="Контекст запроса" aria-live="polite"><div><span class="overline">Ваш запрос</span><h2 id="query-summary-title">{queryTitle}</h2><p>{periodLabel} · {sortLabel}</p></div><div class="query-summary__filters" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить запрос</a>}</div></section>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><div class="result-tools"><span class="result-count">{items.length} на странице · всего {totalItems}</span><nav class="view-toggle" aria-label="Режим отображения"><a class:list={{ "view-toggle__active": view === "list" }} href={viewHref("list")} aria-current={view === "list" ? "page" : undefined}>Список</a><a class:list={{ "view-toggle__active": view === "map" }} href={viewHref("map")} aria-current={view === "map" ? "page" : undefined}>Схема</a></nav></div></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? (view === "map" ? <ActivityMap items={items} /> : <div class="spot-list">{items.map(item => <ActivityCard item={item} periodLabel={periodLabel} />)}</div>) : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p>{filtersChanged && <a data-action="secondary" href="/#results">Показать все данные</a>}</div>}</div>
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section>
{!filterError && !activityUnavailable && <Pagination path="/" params={params} total={totalItems} limit={activityLimit} offset={offset} anchor="#results" itemLabel="точек" />}
{signals.length > 0 && <SignalFeed signals={signals}/>}
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
+22 -6
View File
@@ -1,20 +1,36 @@
---
import EntityMedia from "../components/EntityMedia.astro";
import Pagination from "../components/Pagination.astro";
import PageHero from "../components/PageHero.astro";
import StatePanel from "../components/StatePanel.astro";
import Layout from "../layouts/Layout.astro";
import { api, type MediaAsset } from "../lib/api";
import { mediaRoleLabel } from "../lib/media";
const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
const requested = Astro.url.searchParams.get("type") ?? "fish";
const type = allowed.has(requested) ? requested : "fish";
let assets: MediaAsset[] = [], unavailable = false;
try { assets = await api<MediaAsset[]>(`/api/v1/media/catalog?entity_type=${type}`); } catch { unavailable = true; }
const requestedRole = Astro.url.searchParams.get("role") ?? "";
const search = Astro.url.searchParams.get("q")?.trim() ?? "";
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const pageLimit = 48;
const roleOptions: Record<string, Array<[string, string]>> = { waterbody: [["waterbody_cover", "Заставки"], ["waterbody_map", "Карты"], ["waterbody_depth_map", "Карты глубин"], ["waterbody_screenshot", "Скриншоты"]], tackle: [["tackle_card", "Карточки снастей"], ["tackle_detail", "Детали снастей"], ["rig_diagram", "Схемы сборок"], ["tackle_screenshot", "Скриншоты снастей"]] };
const selectedRole = (roleOptions[type] ?? []).some(([value]) => value === requestedRole) ? requestedRole : "";
let assets: MediaAsset[] = [], allAssets: MediaAsset[] = [], unavailable = false;
try { const query = new URLSearchParams({ entity_type: type }); if (selectedRole) query.set("media_role", selectedRole); allAssets = await api<MediaAsset[]>(`/api/v1/media/catalog?${query}`); if (search) { const needle = search.toLocaleLowerCase("ru-RU"); allAssets = allAssets.filter(asset => (asset.label ?? "").toLocaleLowerCase("ru-RU").includes(needle)); } if (allAssets.length > 0 && offset >= allAssets.length) offset = Math.floor((allAssets.length - 1) / pageLimit) * pageLimit; assets = allAssets.slice(offset, offset + pageLimit); } catch { unavailable = true; }
if (unavailable) {
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
const labels: Record<string,string> = {fish:"Рыбы",waterbody:"Водоёмы",tackle:"Снасти и приманки",reference:"Справочные материалы"};
---
<Layout title={`${labels[type]} RF4 — медиатека RF4 Spotter`} description="Изображения рыб, водоёмов и снастей Russian Fishing 4 с обязательной атрибуцией каждого источника.">
<PageHero eyebrow="Визуальный справочник" title="Медиатека" description="Собранные материалы RF4 с источником у каждого изображения. Каталог пополняется по мере импорта и проверки." variant="fish" count={assets.length} />
<Layout title={`${labels[type]} RF4 — медиатека RF4 Spotter`} description="Изображения рыб, водоёмов и снастей Russian Fishing 4 с обязательной атрибуцией каждого источника." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Визуальный справочник" title="Медиатека" description="Собранные материалы RF4 с источником у каждого изображения. Каталог пополняется по мере импорта и проверки." variant="fish" count={allAssets.length} />
<section class="media-library content-grid">
<nav class="media-library__intro" aria-label="Разделы медиатеки"><strong>{labels[type]} · {assets.length}</strong><span><a href="/media?type=fish">Рыбы</a> · <a href="/media?type=waterbody">Водоёмы</a> · <a href="/media?type=tackle">Снасти</a> · <a href="/media?type=reference">Справка</a></span></nav>
{unavailable ? <StatePanel tone="unavailable" title="Медиатека временно недоступна" /> : assets.length ? <div class="media-library__grid">{assets.map(asset => <article class="media-library__card"><EntityMedia asset={asset} sourceLink /><h2>{asset.label ?? "Без подписи"}</h2><span>{labels[asset.entity_type]}</span></article>)}</div> : <StatePanel title="В этом разделе пока нет изображений" description="Материалы появятся после следующего импорта." />}
<nav class="media-library__intro" aria-label="Разделы медиатеки"><strong>{labels[type]} · {allAssets.length}</strong><span><a href="/media?type=fish">Рыбы</a> · <a href="/media?type=waterbody">Водоёмы</a> · <a href="/media?type=tackle">Снасти</a> · <a href="/media?type=reference">Справка</a></span></nav>
<form class="media-role-filter" method="get"><input type="hidden" name="type" value={type} /><label>Поиск по подписи<input name="q" value={search} maxlength="100" placeholder="Например, щука" /></label>{roleOptions[type]?.length > 0 && <label>Тип материала<select name="role"><option value="">Все роли</option>{roleOptions[type].map(([value, label]) => <option value={value} selected={selectedRole === value}>{label}</option>)}</select></label>}<button data-action="secondary">Показать</button>{(selectedRole || search) && <a data-action="quiet" href={`/media?type=${type}`}>Сбросить</a>}</form>
{unavailable ? <StatePanel tone="unavailable" title="Медиатека временно недоступна" /> : assets.length ? <div class="media-library__grid">{assets.map(asset => <article class="media-library__card"><EntityMedia asset={asset} sourceLink /><h2>{asset.label ?? "Без подписи"}</h2><span>{labels[asset.entity_type]} · {mediaRoleLabel(asset.media_role ?? "")}</span><small>Источник: {asset.source_system}</small></article>)}</div> : <StatePanel title={search ? "Ничего не найдено" : "В этом разделе пока нет изображений"} description="Материалы появятся после следующего импорта." />}
{!unavailable && <Pagination path="/media" params={new URLSearchParams({ type, ...(selectedRole ? { role: selectedRole } : {}), ...(search ? { q: search } : {}) })} total={allAssets.length} limit={pageLimit} offset={offset} itemLabel="материалов" />}
</section>
</Layout>
+108
View File
@@ -0,0 +1,108 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Мой план рыбалки — RF4 Spotter" description="Сохранённые точки рыбалки RF4 без аккаунта: координаты, наживки, свежесть и доверие." noindex>
<section class="plan-hero content-grid"><div><span class="eyebrow">Локально на этом устройстве</span><h1>Мой план<br/><em>рыбалки.</em></h1><p>Соберите до пяти точек, сравните их перед выездом и распечатайте список. Данные не отправляются на сервер.</p></div><div class="plan-hero__mark" aria-hidden="true">♢</div></section>
<section class="plan-toolbar content-grid" aria-label="Действия с планом"><span data-plan-count>0 из 5 точек</span><div><button data-action="secondary" type="button" data-plan-share>Поделиться планом</button><button data-action="secondary" type="button" data-plan-copy>Скопировать ссылку</button><button data-action="secondary" type="button" data-plan-print>Печать / PDF</button><button data-action="quiet" type="button" data-plan-clear>Очистить план</button><small data-plan-share-status aria-live="polite"></small></div></section>
<section class="plan-content content-grid" data-plan-page aria-live="polite"><div class="plan-grid" data-plan-list></div><div class="state" data-plan-empty><h2>План пока пуст</h2><p>Сохраните точку на её detail-странице — она появится здесь.</p><a data-action="secondary" href="/">Посмотреть горячие точки</a></div></section>
</Layout>
<script>
const planStorageKey = "rf4spotter:fishing-plan";
const list = document.querySelector<HTMLElement>("[data-plan-list]");
const empty = document.querySelector<HTMLElement>("[data-plan-empty]");
const count = document.querySelector<HTMLElement>("[data-plan-count]");
const shareStatus = document.querySelector<HTMLElement>("[data-plan-share-status]");
const shareButton = document.querySelector<HTMLButtonElement>("[data-plan-share]");
const copyButton = document.querySelector<HTMLButtonElement>("[data-plan-copy]");
const printButton = document.querySelector<HTMLButtonElement>("[data-plan-print]");
const clearButton = document.querySelector<HTMLButtonElement>("[data-plan-clear]");
const planFieldLimits: Record<string, number> = { key: 512, waterbody: 160, coordinates: 80, baits: 1000, method: 120, retrieve: 160, risk: 160, freshness: 40, confidence: 8 };
const planText = (key: string, value: unknown) => typeof value === "string" ? value.slice(0, planFieldLimits[key] ?? 240) : "";
const normalisePlan = (value: unknown): Array<Record<string, string>> => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
return value.filter(item => item && typeof item === "object" && typeof item.key === "string" && item.key.startsWith("/spots/")).map(item => {
const source = item as Record<string, unknown>;
const entry = Object.fromEntries(["key", "waterbody", "coordinates", "baits", "method", "retrieve", "risk", "freshness", "confidence"].map(key => [key, planText(key, source[key])]));
if (!/^\d{1,3}$/.test(entry.confidence) || Number(entry.confidence) > 100) entry.confidence = "";
return entry;
}).filter(item => {
if (seen.has(item.key)) return false;
seen.add(item.key);
return true;
}).slice(0, 5);
};
const readPlan = (): Array<Record<string, string>> => {
try {
const raw = localStorage.getItem(planStorageKey) || "[]";
const normalised = normalisePlan(JSON.parse(raw));
const serialised = JSON.stringify(normalised);
if (serialised !== raw) localStorage.setItem(planStorageKey, serialised);
return normalised;
}
catch { return []; }
};
const shared = new URLSearchParams(location.search).get("plan");
if (shared) {
try {
const imported = normalisePlan(JSON.parse(shared));
if (imported.length) localStorage.setItem(planStorageKey, JSON.stringify(imported));
else if (shareStatus) shareStatus.textContent = "В ссылке нет сохранённых точек";
} catch { if (shareStatus) shareStatus.textContent = "Ссылка плана не распознана"; }
}
const render = () => {
if (!list || !empty) return;
const plan = readPlan();
list.replaceChildren();
empty.hidden = plan.length > 0;
if (count) count.textContent = `${plan.length} из 5 точек`;
[shareButton, copyButton, printButton, clearButton].forEach(button => { if (button) button.disabled = plan.length === 0; });
plan.forEach(item => {
const card = document.createElement("article"); card.className = "plan-card";
const heading = document.createElement("h2"); heading.textContent = item.waterbody || "Водоём не указан";
const coordinate = document.createElement("strong"); coordinate.textContent = item.coordinates || "Координаты не указаны";
const meta = document.createElement("p"); meta.textContent = item.baits ? `Наживка: ${item.baits.split("|").join(", ")}` : "Наживка не указана";
const approach = document.createElement("p"); approach.textContent = [item.method, item.retrieve].filter(Boolean).join(" · ") || "Метод не указан";
const facts = document.createElement("small"); facts.textContent = [item.risk ? `Риск: ${item.risk}` : "Риск не рассчитан", item.freshness ? "Свежесть сохранена" : "Свежесть не указана", item.confidence !== "" ? `Доверие: ${item.confidence}%` : "Доверие не рассчитано"].join(" · ");
const link = document.createElement("a"); link.href = item.key || "/"; link.textContent = "Открыть точку";
const remove = document.createElement("button"); remove.type = "button"; remove.dataset.action = "quiet"; remove.textContent = "Убрать"; remove.addEventListener("click", () => { localStorage.setItem(planStorageKey, JSON.stringify(readPlan().filter(entry => entry.key !== item.key))); render(); });
const actions = document.createElement("div"); actions.className = "plan-card__actions"; actions.append(link, remove);
card.append(heading, coordinate, meta, approach, facts, actions); list.append(card);
});
};
const planUrl = () => {
const plan = readPlan();
const url = new URL(location.href); url.search = ""; url.searchParams.set("plan", JSON.stringify(plan));
return { plan, value: url.toString() };
};
const copyText = async (value: string) => {
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; }
const field = document.createElement("textarea");
field.value = value; field.setAttribute("readonly", ""); field.style.position = "fixed"; field.style.opacity = "0";
document.body.append(field); field.select();
const copied = document.execCommand("copy");
field.remove();
if (!copied) throw new Error("Clipboard is unavailable");
};
const copyPlanLink = async () => {
const { plan, value } = planUrl();
if (!plan.length) { if (shareStatus) shareStatus.textContent = "Сначала добавьте точку в план"; return; }
await copyText(value);
if (shareStatus) shareStatus.textContent = "Ссылка скопирована";
};
shareButton?.addEventListener("click", async () => {
const { plan, value } = planUrl();
if (!plan.length) { if (shareStatus) shareStatus.textContent = "Сначала добавьте точку в план"; return; }
try {
if (navigator.share) { await navigator.share({ title: "Мой план рыбалки RF4", url: value }); if (shareStatus) shareStatus.textContent = "План отправлен"; }
else await copyPlanLink();
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") { if (shareStatus) shareStatus.textContent = "Поделиться отменено"; return; }
if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку";
}
});
copyButton?.addEventListener("click", async () => { try { await copyPlanLink(); } catch { if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку"; } });
printButton?.addEventListener("click", () => window.print());
clearButton?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); if (shareStatus) shareStatus.textContent = "План очищен"; render(); });
render();
</script>
+21 -7
View File
@@ -4,30 +4,44 @@ import SourceBadge from "../components/SourceBadge.astro";
import SectionHeading from "../components/SectionHeading.astro";
import StatePanel from "../components/StatePanel.astro";
import TackleGlyph from "../components/TackleGlyph.astro";
import Pagination from "../components/Pagination.astro";
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
const params = Astro.url.searchParams;
const fish = params.get("fish") ?? "";
const waterbody = params.get("waterbody") ?? "";
const search = params.get("q") ?? "";
const recordsLimit = 50;
const requestedOffset = Number(params.get("offset") ?? 0);
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let unavailable = false, showNoIndex = false, totalRecords = 0;
try {
const query = new URLSearchParams({ fish, waterbody, limit: "50", offset: String(offset) });
const query = new URLSearchParams({ fish, waterbody, q: search, limit: String(recordsLimit), offset: String(offset) });
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
items = paginated.items;
totalRecords = paginated.total;
if (totalRecords > 0 && offset >= totalRecords) {
offset = Math.floor((totalRecords - 1) / recordsLimit) * recordsLimit;
query.set("offset", String(offset));
items = (await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`)).items;
}
[runs, fishes, waterbodies] = await Promise.all([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"); }
} catch {
unavailable = true;
showNoIndex = true;
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
const last = runs[0];
---
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
<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 data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} из ${totalRecords} записей`} /></div>
<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><label>Игрок или приманка<input name="q" value={search} maxlength="100" placeholder="Например, игрок или блесна" /></label><button data-action="primary">Фильтровать</button>{(fish || waterbody || search) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div>
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" data-action="secondary" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>}
{!unavailable && <Pagination path="/records" params={params} total={totalRecords} limit={recordsLimit} offset={offset} itemLabel="записей" />}
{!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />}
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
</Layout>
+9 -3
View File
@@ -2,11 +2,17 @@
import Layout from "../layouts/Layout.astro";
import { api, type DictionaryItem } from "../lib/api";
let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); }
catch {
unavailable = true;
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
const state = Astro.url.searchParams.get("state");
const reportId = Astro.url.searchParams.get("report_id");
---
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки.">
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки." noindex={unavailable} errorPage={unavailable}>
<span id="report-state" data-state={state ?? ""} hidden></span>
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
@@ -17,7 +23,7 @@ const reportId = Astro.url.searchParams.get("report_id");
{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" && 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 data-action="inverse" 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" role="alert"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
<fieldset><legend>Главное <span>обязательно</span></legend><div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" placeholder="Например, 72" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" placeholder="Например, 84" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" placeholder="1250" required /></label><label>Приманка<input name="bait_name" maxlength="200" placeholder="Название в игре" /></label></div><p class="field-help">Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.</p></fieldset>
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div><label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><label class="consent"><input name="consent" type="checkbox" required /> <span>Я отправляю собственное наблюдение и принимаю <a href="/rules" target="_blank">правила</a> и <a href="/privacy" target="_blank">политику конфиденциальности</a>.</span></label><button data-action="primary" type="submit">Отправить на проверку</button>
</form>}
+59 -8
View File
@@ -1,12 +1,12 @@
---
import Layout from "../../layouts/Layout.astro";
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
import SourceBadge from "../../components/SourceBadge.astro";
import CoordinateRadar from "../../components/CoordinateRadar.astro";
import TackleGlyph from "../../components/TackleGlyph.astro";
import ActivityTimeline from "../../components/ActivityTimeline.astro";
import CatchList from "../../components/CatchList.astro";
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
import DataPassport from "../../components/DataPassport.astro";
import { activityLevel, api, ApiError, coordinatePrecisionLabel, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
let timeline: { start: string; end: string; count: number }[] = [];
@@ -22,19 +22,28 @@ try {
} catch (error) {
unavailable = true;
Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503;
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
if (Astro.response.status === 503) {
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
}
const level = activity ? activityLevel(activity.activity_score) : null;
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
const method = catches.map(item => item.fishing_method).find(Boolean) ?? "";
const retrieve = catches.map(item => item.retrieve_method).find(Boolean) ?? "";
const confirmedMethods = [...new Set(catches.map(item => item.fishing_method).filter((value): value is string => Boolean(value)))].map(value => methodLabels[value] ?? value);
const confirmedRetrieves = [...new Set(catches.map(item => item.retrieve_method).filter((value): value is string => Boolean(value)))];
const risk = activity ? activity.catches < 3 ? "Малая выборка" : activity.confidence_score < 50 ? "Низкая уверенность" : "Подтверждено" : "Нет оценки";
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` },
] } : null;
---
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot || unavailable} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
{unavailable || !spot ? <div class="state" role="alert"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><p class="coordinate-precision">Точность координат: <strong>{coordinatePrecisionLabel(spot.coordinate_precision)}</strong></p><div class="spot-hero__actions"><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><button class="plan-save" data-action="inverse" type="button" data-plan-save data-plan-key={Astro.url.pathname} data-plan-waterbody={spot.waterbody} data-plan-coordinates={`${spot.x}:${spot.y}`} data-plan-baits={spot.top_baits.join("|")} data-plan-method={methodLabels[method] ?? method} data-plan-retrieve={retrieve} data-plan-risk={risk} data-plan-freshness={activity?.last_confirmed_at ?? ""} data-plan-confidence={activity?.confidence_score ?? ""} aria-pressed="false">Сохранить в план</button><small class="copy-status" aria-live="polite"></small><small class="plan-status" aria-live="polite"></small></div></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
<ActivityTimeline buckets={timeline}/>
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
@@ -43,17 +52,59 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
<div class="section-heading"><h2>Последние уловы</h2></div>
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
</div>
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} coordinateSources={activity.coordinate_sources} sourceConflicts={activity.source_conflicts} status={activity.catches < 3 ? "insufficient" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<section class="confirmed-approach" aria-label="Подтверждённый метод и проводка"><h3>Подтверждённый подход</h3>{confirmedMethods.length || confirmedRetrieves.length ? <dl>{confirmedMethods.length > 0 && <div><dt>Метод</dt><dd>{confirmedMethods.join(", ")}</dd></div>}{confirmedRetrieves.length > 0 && <div><dt>Проводка</dt><dd>{confirmedRetrieves.join(", ")}</dd></div>}</dl> : <p>Метод и проводка в уловах не указаны.</p>}<small>По одобренным уловам этой точки.</small></section><p class="note">Учитываются только одобренные наблюдения.</p></aside>
</section>
</>}
</Layout>
<script>
const copyText = async (value: string) => {
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; }
const field = document.createElement("textarea");
field.value = value; field.setAttribute("readonly", ""); field.style.position = "fixed"; field.style.opacity = "0";
document.body.append(field); field.select();
const copied = document.execCommand("copy");
field.remove();
if (!copied) throw new Error("Clipboard is unavailable");
};
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
button.addEventListener("click", async () => {
const value = button.dataset.copyCoordinates || "";
const status = button.parentElement?.querySelector<HTMLElement>(".copy-status");
try { await navigator.clipboard.writeText(value); if (status) status.textContent = "Скопировано"; }
try { await copyText(value); if (status) status.textContent = "Скопировано"; }
catch { if (status) status.textContent = value; }
});
});
const planStorageKey = "rf4spotter:fishing-plan";
const readPlan = (): Array<Record<string, string>> => {
try { const value = JSON.parse(localStorage.getItem(planStorageKey) || "[]"); return Array.isArray(value) ? value : []; }
catch { return []; }
};
document.querySelectorAll<HTMLButtonElement>("[data-plan-save]").forEach((button) => {
const key = button.dataset.planKey || "";
const status = button.parentElement?.querySelector<HTMLElement>(".plan-status");
const sync = () => {
const saved = readPlan().some(item => item.key === key);
button.textContent = saved ? "В плане" : "Сохранить в план";
button.setAttribute("aria-pressed", String(saved));
button.dataset.saved = String(saved);
};
sync();
button.addEventListener("click", () => {
const plan = readPlan();
const index = plan.findIndex(item => item.key === key);
if (index >= 0) { plan.splice(index, 1); if (status) status.textContent = "Удалено из плана"; }
else { plan.unshift({ key, waterbody: button.dataset.planWaterbody || "", coordinates: button.dataset.planCoordinates || "", baits: button.dataset.planBaits || "", method: button.dataset.planMethod || "", retrieve: button.dataset.planRetrieve || "", risk: button.dataset.planRisk || "", freshness: button.dataset.planFreshness || "", confidence: button.dataset.planConfidence || "" }); if (status) status.textContent = "Добавлено в план"; }
localStorage.setItem(planStorageKey, JSON.stringify(plan.slice(0, 5)));
sync();
});
});
</script>
<style>
.confirmed-approach { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
.confirmed-approach h3 { margin: 0 0 12px; }
.confirmed-approach dl { display: grid; gap: 8px; margin: 0 0 8px; }
.confirmed-approach dl div { display: grid; grid-template-columns: 92px 1fr; gap: 8px; }
.confirmed-approach dt { color: var(--text-subtle); font-size: 11px; font-weight: 750; text-transform: uppercase; }
.confirmed-approach dd { margin: 0; color: var(--text-secondary); }
.confirmed-approach p, .confirmed-approach small { color: var(--text-muted); }
</style>
+10 -5
View File
@@ -4,13 +4,18 @@ import SourceBadge from "../components/SourceBadge.astro";
import DataLegend from "../components/DataLegend.astro";
import PageHero from "../components/PageHero.astro";
import StatePanel from "../components/StatePanel.astro";
import { ago, api, plural, type SourceStatus } from "../lib/api";
import { ago, api, plural, sourceStatusLabel, type SourceStatus } from "../lib/api";
let sources: SourceStatus[] = [], unavailable = false;
try { sources = await api<SourceStatus[]>("/api/v1/source-status"); } catch { unavailable = true; }
const labels = { healthy:"Актуален", stale:"Данные устарели", temporarily_limited:"Временная пауза", source_changed:"Источник изменился", waiting:"Ожидает запуска", disabled:"Выключен" };
try { sources = await api<SourceStatus[]>("/api/v1/source-status"); }
catch {
unavailable = true;
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
---
<Layout title="Состояние источников — RF4 Spotter" description="Текущее безопасное состояние источников данных RF4 Spotter.">
<Layout title="Состояние источников — RF4 Spotter" description="Текущее безопасное состояние источников данных RF4 Spotter." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Прозрачность данных" title="Статус источников" description="Пауза или изменение страницы не удаляют уже опубликованные данные." />
{unavailable ? <StatePanel tone="unavailable" title="Статус временно недоступен" description="Данные о состоянии источников не получены. Публичные наблюдения при этом не изменяются." /> : <section class="source-health-grid content-grid">{sources.map(source => <article data-status={source.status}><div><SourceBadge source={source.source_system}/><span class="health-state"><i></i>{labels[source.status]}</span></div><h2>{source.name}</h2><strong>{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}</strong><p>{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}</p></article>)}</section>}
{unavailable ? <StatePanel tone="unavailable" title="Статус временно недоступен" description="Данные о состоянии источников не получены. Публичные наблюдения при этом не изменяются." /> : <section class="source-health-grid content-grid">{sources.map(source => <article data-status={source.status}><div><SourceBadge source={source.source_system}/><span class="health-state"><i></i>{sourceStatusLabel(source.status)}</span></div><h2>{source.name}</h2><strong>{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}</strong><p>{source.status === "temporarily_limited" ? "Источник ограничен; опубликованные данные сохранены." : source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}</p></article>)}</section>}
<DataLegend />
</Layout>
+43
View File
@@ -0,0 +1,43 @@
---
import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import { ago, api, type DictionaryItem, type TackleCombination } from "../../lib/api";
const params = Astro.url.searchParams;
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
const method = params.get("method") ?? "";
const role = params.get("role") ?? "";
const hours = [24, 72, 168].includes(Number(params.get("hours"))) ? Number(params.get("hours")) : 72;
const query = new URLSearchParams({ hours: String(hours), min_samples: "3", min_players: "2" });
if (waterbody) query.set("waterbody", waterbody);
if (fish) query.set("fish", fish);
if (method) query.set("method", method);
if (role) query.set("role", role);
let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false;
try {
[combinations, waters, fishes] = await Promise.all([
api<TackleCombination[]>(`/api/v1/analytics/tackle?${query}`),
api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"),
api<DictionaryItem[]>("/api/v1/fishes?limit=500"),
]);
} catch { unavailable = true; }
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
---
<Layout title="Сочетания снастей RF4 — RF4 Spotter" description="Подтверждённые сочетания снастей RF4 по одобренным наблюдениям, с выборкой и независимыми игроками." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Только одобренные наблюдения" title="Сочетания снастей" description="Факт использования отделён от рекомендации: малой выборке нельзя приписывать эффективность." variant="fish" />
<form class="record-filters" method="get" aria-label="Фильтры сочетаний снастей">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waters.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label>
<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="method"><option value="">Все методы</option>{Object.entries(methodLabels).map(([value, label]) => <option value={value} selected={method === value}>{label}</option>)}</select></label>
<label>Роль<select name="role"><option value="">Все роли</option>{Object.entries(roleLabels).map(([value, label]) => <option value={value} selected={role === value}>{label}</option>)}</select></label>
<label>Период<select name="hours">{[24, 72, 168].map(value => <option value={value} selected={hours === value}>{value} ч</option>)}</select></label>
<button data-action="primary">Показать</button>
{(waterbody || fish || method || role || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
</form>
<p class="privacy content-grid">Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.</p>
{unavailable ? <StatePanel tone="unavailable" title="Аналитика временно недоступна" description="Не показываем непроверенные сочетания. Попробуйте позже." /> : combinations.length ? <section class="signal-grid content-grid" aria-label="Сочетания снастей" data-analytics-results>{combinations.map(item => { const canonicalHref = item.rig_id ? `/tackle/rigs/${item.rig_id}` : item.tackle_item_id ? `/tackle/items/${item.tackle_item_id}` : null; return <article class="signal-card" data-analytics-status={item.status}><div class="signal-card__top"><span class="source-chip"><span>{roleLabels[item.role] ?? item.role}</span></span><span class="quality-chip">{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}</span></div><h2>{canonicalHref ? <a href={canonicalHref}>{item.value}</a> : item.value}</h2><dl><div><dt>Наблюдения</dt><dd>{item.catches}</dd></div><div><dt>Игроки</dt><dd>{item.unique_players}</dd></div><div><dt>Свежесть выборки</dt><dd>{item.freshness_score}%</dd></div><div><dt>Последнее</dt><dd>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>})}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}
</Layout>
+57
View File
@@ -0,0 +1,57 @@
---
import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro";
import TackleGlyph from "../../components/TackleGlyph.astro";
import DataPassport from "../../components/DataPassport.astro";
import { api, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api";
const params = Astro.url.searchParams;
const category = params.get("category") ?? "";
const search = params.get("q") ?? "";
const brand = params.get("brand") ?? "";
const family = params.get("family") ?? "";
const unlockLevelParam = params.get("unlock_level") ?? "";
const unlockLevel = /^\d+$/.test(unlockLevelParam) ? unlockLevelParam : "";
const requestedOffset = Number(params.get("offset") ?? 0);
const limit = 48;
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const query = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (category) query.set("category", category);
if (search) query.set("q", search);
if (brand) query.set("brand", brand);
if (family) query.set("family", family);
if (unlockLevel) query.set("unlock_level", unlockLevel);
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset }, unavailable = false, rigsUnavailable = false;
const [itemsResult, rigsResult] = await Promise.allSettled([
api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`),
api<PaginatedRigs>(`/api/v1/tackle/rigs?limit=${limit}&offset=${offset}${search ? `&q=${encodeURIComponent(search)}` : ""}`),
]);
if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true;
if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true;
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
const filterParams = new URLSearchParams();
if (category) filterParams.set("category", category);
if (search) filterParams.set("q", search);
if (brand) filterParams.set("brand", brand);
if (family) filterParams.set("family", family);
if (unlockLevel) filterParams.set("unlock_level", unlockLevel);
---
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
<label>Поиск<input name="q" value={search} maxlength="100" placeholder="Название снасти или приманки" /></label>
<label>Бренд<input name="brand" value={brand} maxlength="100" placeholder="Например, RF4" /></label>
<label>Семейство<input name="family" value={family} maxlength="100" placeholder="Название семейства" /></label>
<label>Уровень открытия<input name="unlock_level" value={unlockLevel} inputmode="numeric" pattern="[0-9]+" min="0" maxlength="4" placeholder="Например, 20" /></label>
<button data-action="primary">Фильтровать</button>
{(category || search || brand || family || unlockLevel) && <a data-action="quiet" href="/tackle">Сбросить</a>}
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small>{item.unlock_level !== null && <small>Открывается с уровня {item.unlock_level}</small>}<div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
{!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Сборка · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых сборок пока нет" description="Сборки появятся после разрешённой загрузки и проверки источников." />}</section>}
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
</Layout>
@@ -0,0 +1,59 @@
---
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
import DataPassport from "../../../components/DataPassport.astro";
import EntityMedia from "../../../components/EntityMedia.astro";
import Layout from "../../../layouts/Layout.astro";
import PageHero from "../../../components/PageHero.astro";
import StatePanel from "../../../components/StatePanel.astro";
import TackleGlyph from "../../../components/TackleGlyph.astro";
import { ApiError, api, type MediaAsset, type TackleItem } from "../../../lib/api";
import { findMediaByLabel, mediaRoleLabel } from "../../../lib/media";
import { safeHttpUrl } from "../../../lib/urls";
const { id } = Astro.params;
let item: TackleItem | undefined, media: MediaAsset[] = [];
let unavailable = false;
const [itemResult, mediaResult] = await Promise.allSettled([
api<TackleItem>(`/api/v1/tackle/items/${encodeURIComponent(id ?? "")}`),
api<MediaAsset[]>("/api/v1/media/catalog?entity_type=tackle"),
]);
if (itemResult.status === "fulfilled") item = itemResult.value;
else { const error = itemResult.reason; unavailable = !(error instanceof ApiError) || error.status >= 500; }
if (mediaResult.status === "fulfilled") media = mediaResult.value;
if (!item && !unavailable) Astro.response.status = 404;
if (unavailable) {
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
const missingLabels: Record<string, string> = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" };
const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
const sourceHref = safeHttpUrl(item?.source_url);
const mediaCandidates = item ? (["tackle_card", "tackle_detail"] as const).map(role => findMediaByLabel(media, item!.name, [role])).filter((asset): asset is MediaAsset => Boolean(asset)) : [];
---
<Layout title={item ? `${item.name} — снасти RF4` : "Снасть не найдена — RF4 Spotter"} description={item ? `Подтверждённая карточка ${item.name} в каталоге снастей RF4 с источником и отметками полноты.` : "Такой карточки нет в каталоге снастей RF4."} noindex={!item || unavailable} errorPage={!item || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: item?.name ?? "Не найдено" }]} />
<PageHero eyebrow={item ? categoryLabels[item.category] ?? "Каталог RF4" : "Каталог RF4"} title={item?.name ?? (unavailable ? "Каталог недоступен" : "Снасть не найдена")} description={item ? "Канонические характеристики и происхождение без догадок." : undefined} variant={item ? "fish" : undefined} identity={item?.name} />
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенные характеристики не показываем." /> : !item ? <StatePanel tone="error" title="Такой карточки нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог" /> : <>
<section class="tackle-detail content-grid" aria-label="Характеристики снасти">
<div class="tackle-detail__identity"><TackleGlyph name={item.name} size={72} /><span class="overline">{categoryLabels[item.category] ?? item.category}</span><h2>{item.name}</h2><p>{[item.brand, item.family].filter(Boolean).join(" · ") || "Бренд и семейство не указаны."}</p></div>
<div><dl><div><dt>Подкатегория</dt><dd>{item.subcategory ?? "Не указана"}</dd></div><div><dt>Уровень открытия</dt><dd>{item.unlock_level ?? "Не указан"}</dd></div><div><dt>Источник</dt><dd>{item.source_system ?? "Не указан"}</dd></div></dl>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div>
</section>
<DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"} />
<section class="related-rigs content-grid" aria-labelledby="related-rigs-title"><div><span class="overline">Связанные сборки</span><h2 id="related-rigs-title">Где используется снасть</h2><p>Показываем только сборки, где компонент привязан к канонической карточке.</p></div>{item.related_rigs.length ? <nav class="related-rigs__list">{item.related_rigs.map(rig => <a href={`/tackle/rigs/${rig.id}`}><strong>{rig.name}</strong><span>{rig.component_count} компонентов · {rig.source_system ?? "Источник не указан"}</span></a>)}</nav> : <p>Подтверждённых связанных сборок пока нет.</p>}</section>
{mediaCandidates.length > 0 && <section class="catalog-media-grid content-grid" aria-label={`Изображения снасти: ${item.name}`}>{mediaCandidates.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
</>}
</Layout>
<style>
.related-rigs { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); gap: 24px; margin-block: 26px; }
.related-rigs h2 { margin: 6px 0 8px; }
.related-rigs p { margin: 0; color: var(--text-muted); line-height: 1.5; }
.related-rigs__list { display: grid; gap: 10px; }
.related-rigs__list a { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 14px; color: inherit; text-decoration: none; }
.related-rigs__list a:hover, .related-rigs__list a:focus-visible { border-color: var(--focus); }
.related-rigs__list span { color: var(--text-muted); font-size: 12px; }
@media (max-width: 720px) { .related-rigs { grid-template-columns: 1fr; } }
</style>
+54
View File
@@ -0,0 +1,54 @@
---
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
import DataPassport from "../../../components/DataPassport.astro";
import EntityMedia from "../../../components/EntityMedia.astro";
import Layout from "../../../layouts/Layout.astro";
import PageHero from "../../../components/PageHero.astro";
import StatePanel from "../../../components/StatePanel.astro";
import TackleGlyph from "../../../components/TackleGlyph.astro";
import { ApiError, api, type MediaAsset, type Rig } from "../../../lib/api";
import { findMediaByLabel, mediaRoleLabel } from "../../../lib/media";
import { safeHttpUrl } from "../../../lib/urls";
const { id } = Astro.params;
let rig: Rig | undefined, media: MediaAsset[] = [];
let unavailable = false;
const [rigResult, mediaResult] = await Promise.allSettled([
api<Rig>(`/api/v1/tackle/rigs/${encodeURIComponent(id ?? "")}`),
api<MediaAsset[]>("/api/v1/media/catalog?entity_type=tackle"),
]);
if (rigResult.status === "fulfilled") rig = rigResult.value;
else { const error = rigResult.reason; unavailable = !(error instanceof ApiError) || error.status >= 500; }
if (mediaResult.status === "fulfilled") media = mediaResult.value;
if (!rig && !unavailable) Astro.response.status = 404;
if (unavailable) {
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
const missingLabels: Record<string, string> = { source_url: "ссылка на источник", source_checked_at: "дата проверки" };
const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
const sourceHref = safeHttpUrl(rig?.source_url);
const mediaCandidates = rig ? [findMediaByLabel(media, rig.name, ["rig_diagram"])].filter((asset): asset is MediaAsset => Boolean(asset)) : [];
---
<Layout title={rig ? `${rig.name} — сборки RF4` : "Сборка не найдена — RF4 Spotter"} description={rig ? `Подтверждённая карточка сборки ${rig.name} в каталоге RF4 с составом и источником.` : "Такой карточки сборки нет в каталоге RF4."} noindex={!rig || unavailable} errorPage={!rig || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: "Сборки" }, { label: rig?.name ?? "Не найдено" }]} />
<PageHero eyebrow="Каноническая сборка" title={rig?.name ?? (unavailable ? "Каталог недоступен" : "Сборка не найдена")} description={rig ? "Состав сборки показан в исходном порядке; неподтверждённые значения не дополняются догадками." : undefined} variant={rig ? "fish" : undefined} identity={rig?.name} />
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенный состав не показываем." /> : !rig ? <StatePanel tone="error" title="Такой сборки нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог снастей" /> : <>
<section class="tackle-detail content-grid" aria-label="Состав сборки">
<div class="tackle-detail__identity"><TackleGlyph name={rig.name} size={72} /><span class="overline">Сборка</span><h2>{rig.name}</h2><p>{rig.source_external_id ? `Идентификатор источника: ${rig.source_external_id}` : "Внешний идентификатор не указан."}</p></div>
<div><span class="overline">Компоненты</span><ol class="rig-components">{rig.components.length ? rig.components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value ?? "Карточка снасти"}</a> : <span>{component.raw_value ?? "Значение не указано"}</span>}</li>) : <li>Состав не указан.</li>}</ol>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div>
</section>
<DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"} />
{mediaCandidates.length > 0 && <section class="catalog-media-grid content-grid" aria-label={`Изображения сборки: ${rig.name}`}>{mediaCandidates.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
</>}
</Layout>
<style>
.rig-components { display: grid; gap: 10px; margin: 14px 0 20px; padding-left: 22px; }
.rig-components li { padding-left: 4px; color: var(--text-secondary); }
.rig-components li > span:first-child { display: inline-block; min-width: 110px; margin-right: 8px; color: var(--text-subtle); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
.rig-components a { color: inherit; text-underline-offset: 3px; }
</style>
+33 -7
View File
@@ -1,20 +1,38 @@
---
import ActivityCard from "../../components/ActivityCard.astro";
import ActivityEvidenceSummary from "../../components/ActivityEvidenceSummary.astro";
import ActivityPeriodFilter from "../../components/ActivityPeriodFilter.astro";
import EntityMedia from "../../components/EntityMedia.astro";
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import DataPassport from "../../components/DataPassport.astro";
import Layout from "../../layouts/Layout.astro";
import Pagination from "../../components/Pagination.astro";
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
import { findMediaByLabel } from "../../lib/media";
import { findMediaByLabel, mediaRoleLabel } from "../../lib/media";
import { safeHttpUrl } from "../../lib/urls";
const { slug } = Astro.params;
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
const requestedHours = Astro.url.searchParams.get("hours") ?? "72";
const hours = ["24", "72", "168"].includes(requestedHours) ? requestedHours : "72";
const periodLabel = { "24": "24 часа", "72": "72 часа", "168": "7 дней" }[hours];
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const activityLimit = 24;
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], totalItems = 0, unavailable = false;
try {
const [waters, waterMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=waterbody")]);
media = waterMedia;
water = waters.find(item => item.slug === slug);
if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
if (water) {
let paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&limit=${activityLimit}&offset=${offset}`);
if (paginated.total > 0 && offset >= paginated.total) {
offset = Math.floor((paginated.total - 1) / activityLimit) * activityLimit;
paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&limit=${activityLimit}&offset=${offset}`);
}
items = paginated.items; totalItems = paginated.total;
}
} catch { unavailable = true; }
if (unavailable) {
Astro.response.status = 503;
@@ -23,12 +41,20 @@ if (unavailable) {
}
if (!water && !unavailable) Astro.response.status = 404;
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
const image = water ? findMediaByLabel(media, water.name_ru) : undefined;
const coverImage = water ? findMediaByLabel(media, water.name_ru, ["waterbody_cover"]) : undefined;
const supportingMedia = water ? (["waterbody_map", "waterbody_depth_map"] as const).map(role => findMediaByLabel(media, water!.name_ru, [role])).filter((asset): asset is MediaAsset => Boolean(asset)) : [];
const sourceHref = safeHttpUrl(water?.source_url);
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
---
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
<PageHero eyebrow={`Свежие данные за ${periodLabel}`} title={water?.name_ru ?? "Водоём не найден"} description={water ? `${totalItems} ${plural(totalItems,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
{water && <ActivityPeriodFilter hours={hours} />}
{coverImage && <section class="entity-feature content-grid" aria-label={`Заставка: ${water!.name_ru}`}><EntityMedia asset={coverImage} sourceLink /><div><span class="overline">{mediaRoleLabel("waterbody_cover")}</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
{supportingMedia.length > 0 && <section class="waterbody-media-grid content-grid" aria-label={`Карты: ${water!.name_ru}`}>{supportingMedia.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
{water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) && <section class="data-passport content-grid" aria-label="Паспорт водоёма"><div><span class="overline">Канонические сведения</span><h2>{water.name_ru}</h2>{water.description && <p>{water.description}</p>}{water.unlock_level !== undefined && water.unlock_level !== null && <p>Открывается с уровня: <strong>{water.unlock_level}</strong></p>}{water.source_fish_species?.length && <p>В карточке источника указано видов рыб: <strong>{water.source_fish_species.length}</strong>.</p>}{water.source_aliases?.length && <p>Алиасы источника: {water.source_aliases.join(", ")}.</p>}</div><div><span class="overline">Источник</span>{sourceHref ? <p><a href={sourceHref} rel="noreferrer">{water.source_system ?? "Внешний источник"} · исходная карточка</a></p> : <p>Источник для описания ещё не подтверждён.</p>}{water.source_point_urls?.length && <p>Сохранено ссылок на точки: <strong>{water.source_point_urls.length}</strong>.</p>}{water.source_image_urls?.length && <p>Изображений-кандидатов: <strong>{water.source_image_urls.length}</strong>; публикация требует отдельной проверки.</p>}{water.source_checked_at && <p>Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}</p>}</div></section>}
{water && <DataPassport sources={water.source_system ? [water.source_system] : []} sourceUrl={water.source_url} observedAt={water.source_checked_at} status={water.source_checked_at ? "verified" : "unverified"}/>}
{water && <ActivityEvidenceSummary items={items} totalPoints={totalItems} periodLabel={periodLabel} />}
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}?hours=${hours}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel={periodLabel}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Свежих подтверждённых наблюдений пока нет." />}</div></section>}
{water && <Pagination path={`/waterbodies/${water.slug}`} params={new URLSearchParams({ hours })} total={totalItems} limit={activityLimit} offset={offset} itemLabel="точек" />}
</Layout>
@@ -1,16 +1,32 @@
---
import ActivityCard from "../../../components/ActivityCard.astro";
import ActivityEvidenceSummary from "../../../components/ActivityEvidenceSummary.astro";
import ActivityPeriodFilter from "../../../components/ActivityPeriodFilter.astro";
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
import PageHero from "../../../components/PageHero.astro";
import StatePanel from "../../../components/StatePanel.astro";
import Layout from "../../../layouts/Layout.astro";
import Pagination from "../../../components/Pagination.astro";
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../../lib/api";
const { slug, fish: fishSlug } = Astro.params;
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
const requestedHours = Astro.url.searchParams.get("hours") ?? "72";
const hours = ["24", "72", "168"].includes(requestedHours) ? requestedHours : "72";
const periodLabel = { "24": "24 часа", "72": "72 часа", "168": "7 дней" }[hours];
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const activityLimit = 24;
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], totalItems = 0, unavailable = false;
try {
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);
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; }
if (water && fish) {
let paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
if (paginated.total > 0 && offset >= paginated.total) {
offset = Math.floor((paginated.total - 1) / activityLimit) * activityLimit;
paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
}
items = paginated.items; totalItems = paginated.total;
}
} catch { unavailable = true; }
if (unavailable) {
Astro.response.status = 503;
@@ -23,6 +39,9 @@ const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на $
---
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено", href: water ? `/waterbodies/${water.slug}` : undefined }, { label: fish?.name_ru ?? "Не найдено" }]} />
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${items.length} ${plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${totalItems} ${plural(totalItems,["свежая точка","свежие точки","свежих точек"])} за ${periodLabel}.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
{valid && <ActivityPeriodFilter hours={hours} />}
{valid && <ActivityEvidenceSummary items={items} totalPoints={totalItems} periodLabel={periodLabel} />}
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel={periodLabel}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
{valid && <Pagination path={`/waterbodies/${water!.slug}/${fish!.slug}`} params={new URLSearchParams({ hours })} total={totalItems} limit={activityLimit} offset={offset} itemLabel="точек" />}
</Layout>
+19 -5
View File
@@ -3,16 +3,30 @@ import Layout from "../../layouts/Layout.astro";
import PageHero from "../../components/PageHero.astro";
import StatePanel from "../../components/StatePanel.astro";
import WaterbodyMark from "../../components/WaterbodyMark.astro";
import Pagination from "../../components/Pagination.astro";
import { api, type DictionaryItem } from "../../lib/api";
let waters: DictionaryItem[] = [], unavailable = false;
try { waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; }
const params = Astro.url.searchParams;
const search = params.get("q")?.trim() ?? "";
const pageLimit = 48;
const requestedOffset = Number(params.get("offset") ?? 0);
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
let waters: DictionaryItem[] = [], allWaters: DictionaryItem[] = [], unavailable = false;
try {
allWaters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
const matchingWaters = search ? allWaters.filter(item => item.name_ru.toLocaleLowerCase("ru-RU").includes(search.toLocaleLowerCase("ru-RU"))) : allWaters;
if (matchingWaters.length > 0 && offset >= matchingWaters.length) offset = Math.floor((matchingWaters.length - 1) / pageLimit) * pageLimit;
waters = matchingWaters.slice(offset, offset + pageLimit);
allWaters = matchingWaters;
} catch { unavailable = true; }
if (unavailable) {
Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60");
Astro.response.headers.set("Cache-Control", "no-store");
}
---
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={waters.length} />
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>}
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных." noindex={unavailable} errorPage={unavailable}>
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={allWaters.length} />
<form class="record-filters content-grid" method="get" aria-label="Поиск водоёма"><label>Поиск по названию<input name="q" value={search} maxlength="100" placeholder="Например, Вьюнок" /></label><button data-action="primary">Найти</button>{search && <a data-action="quiet" href="/waterbodies">Сбросить</a>}</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : waters.length ? <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(offset + index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav> : <StatePanel title="Ничего не найдено" description="Попробуйте изменить запрос или открыть полный каталог водоёмов." />}
{!unavailable && <Pagination path="/waterbodies" params={params} total={allWaters.length} limit={pageLimit} offset={offset} itemLabel="водоёмов" />}
</Layout>
+2
View File
@@ -1 +1,3 @@
.dashboard:has(>.results-column:only-child){grid-template-columns:1fr}
.result-tools{display:flex;align-items:center;gap:10px}.view-toggle{display:flex;padding:3px;border:1px solid var(--border);border-radius:999px;background:var(--surface-soft)}.view-toggle a{padding:7px 10px;border-radius:999px;color:var(--text-muted);font-size:11px;font-weight:750;text-decoration:none}.view-toggle a:hover,.view-toggle__active{background:var(--surface);color:var(--text)!important;box-shadow:0 2px 8px color-mix(in srgb,var(--deep) 10%,transparent)}
@media(max-width:720px){.section-heading{align-items:start}.result-tools{display:grid;justify-items:end;gap:7px}.view-toggle a{padding:6px 9px}}
+5 -3
View File
@@ -1,5 +1,7 @@
.data-passport{margin-top:18px;padding:14px;border:1px solid var(--border);border-radius:13px;background:var(--surface-soft);color:var(--deep)}
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"]{background:var(--warning-soft);color:var(--warning)}
.data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)}
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"],.data-passport header strong[data-passport-status="insufficient"],.data-passport header strong[data-passport-status="blocked"],.data-passport header strong[data-passport-status="stale"]{background:var(--warning-soft);color:var(--warning)}
.data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)}
.data-passport__minimum{margin:12px 0 0;color:var(--warning);font-size:11px;line-height:1.4}
.data-passport__conflict{margin:12px 0 0;padding:9px 10px;border-left:3px solid var(--warning);background:var(--warning-soft);color:var(--warning);font-size:11px;line-height:1.4}.data-passport__conflict strong{font-weight:750}.data-passport__coordinate-sources{margin-top:12px;border-top:1px solid var(--border-soft);padding-top:10px}.data-passport__coordinate-sources summary{cursor:pointer;color:var(--text-secondary);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.06em}.data-passport__coordinate-sources>div{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.detail-card .data-passport,.detail-grid aside .data-passport{border-color:#ffffff20;background:#ffffff08;color:#fff}.detail-card .data-passport dd,.detail-grid aside .data-passport dd{color:#fff}.detail-card .data-passport dl div,.detail-grid aside .data-passport dl div{border-color:#ffffff1c}.detail-card .data-passport dt,.detail-grid aside .data-passport dt{color:#a9b8b5}.signal-card .data-passport{position:relative;z-index:1;margin-top:15px;background:#fffdf7;border-color:#ddd3af}
@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
@media(max-width:720px){.data-passport dl{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
+11 -1
View File
@@ -17,16 +17,26 @@
.admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px}
.admin-shortcuts{margin:14px 0;color:var(--text-muted);font-size:12px}.admin-shortcuts kbd{padding:2px 6px;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;background:var(--surface);font:700 11px Inter,sans-serif}.moderation-card:focus-within{outline:2px solid var(--focus);outline-offset:3px}
.moderation-provenance{margin-top:18px;border-top:1px solid var(--border-soft);padding-top:14px}.moderation-provenance summary{cursor:pointer;color:var(--deep);font-weight:700}.moderation-provenance dl{margin-top:14px}.moderation-provenance p{margin:12px 0 0;color:var(--text-muted);font-size:12px;line-height:1.5}
.queue-select-all{display:inline-flex;align-items:center;gap:8px;margin:0 0 12px;color:var(--text-muted);font-size:13px}.moderation-select{display:block;grid-column:1/-1}.moderation-select input{width:18px;height:18px}.moderation-bulk,[data-external-bulk]{display:flex;align-items:end;flex-wrap:wrap;gap:12px;margin:0 0 18px;padding:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface);color:var(--text)}.moderation-bulk strong,[data-external-bulk] strong{margin-right:auto}.moderation-bulk label,[data-external-bulk] label{min-width:180px;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}.moderation-bulk textarea,[data-external-bulk] textarea,.moderation-bulk select,[data-external-bulk] select{display:block;width:100%;margin-top:5px;padding:9px;border:1px solid var(--border);border-radius:7px;background:var(--surface-field);color:var(--text);resize:vertical}.moderation-bulk button,[data-external-bulk] button{min-height:42px;padding:0 15px;border:0;border-radius:8px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-bulk button[data-action="danger"],[data-external-bulk] button[data-action="danger"]{background:var(--danger-soft);color:var(--danger)}
@media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}}
.admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)}
@media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}}
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 .brand-name span{color:#9dafaa}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: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}}
/* The tablet header needs the same two-row layout as mobile: the full theme
switcher and horizontal navigation cannot share one 720px row. */
@media (min-width:721px) and (max-width:900px){
.topbar{width:100%;padding:13px 24px 0;display:flex;flex-wrap:wrap;height:auto}
.topbar .brand{flex:1;min-width:0}
.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}
.topbar nav a{flex:0 0 auto;font-size:13px}
}
@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 strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01s!important;animation-iteration-count:1!important}}
.activity-legend{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0;color:#7d918b;font-size:11px}.activity-legend span:before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;background:var(--lime)}.activity-legend span:nth-child(2):before{background:#e5b95c}.activity-legend span:nth-child(3):before{background:#ef795e}
/* RF4 field-guide iconography and float activity gauge */
+6
View File
@@ -1,2 +1,8 @@
.entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}}
.entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}}
.waterbody-media-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;padding-block:28px}.waterbody-media-grid article{min-width:0;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface-soft)}.waterbody-media-grid article>.overline{display:block;margin-bottom:10px}.waterbody-media-grid .entity-media__frame{min-height:220px}@media(max-width:720px){.waterbody-media-grid{grid-template-columns:1fr;padding-block:20px}.waterbody-media-grid .entity-media__frame{min-height:190px}}
.catalog-media-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:18px;padding-block:28px 70px}.catalog-media-grid article{min-width:0;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface-soft)}.catalog-media-grid article>.overline{display:block;margin-bottom:10px}.catalog-media-grid .entity-media__frame{min-height:220px}
.media-role-filter{display:flex;align-items:end;gap:10px;margin:0 0 22px;padding:14px 16px;border:1px solid var(--border-soft);border-radius:12px;background:var(--surface-soft)}.media-role-filter label{display:grid;gap:6px;color:var(--text-muted);font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.08em}.media-role-filter select{min-width:220px;height:40px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text)}.media-role-filter a{padding:11px 4px;color:var(--text-muted);font-size:12px}@media(max-width:720px){.media-role-filter{align-items:stretch;flex-wrap:wrap}.media-role-filter label{flex:1 1 100%}.media-role-filter select{width:100%}}
@media (min-width:721px) and (max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
+1
View File
@@ -0,0 +1 @@
.pagination{width:min(1360px,calc(100% - 64px));margin:22px auto 48px;display:flex;align-items:center;justify-content:space-between;gap:16px;color:var(--text-secondary);font-size:13px}.pagination>div{display:flex;gap:10px}.pagination a,.pagination__disabled{display:inline-flex;align-items:center;min-height:42px;padding:10px 15px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface-elevated);color:var(--text-secondary);text-decoration:none}.pagination a:hover{background:var(--surface-raised);color:var(--text-primary)}.pagination__disabled{opacity:.5}@media(max-width:720px){.pagination{width:calc(100% - 28px);align-items:stretch;flex-direction:column}.pagination>div{display:grid;grid-template-columns:1fr 1fr}.pagination a,.pagination__disabled{justify-content:center;text-align:center}}
+3
View File
@@ -0,0 +1,3 @@
.plan-hero{padding:70px 0 45px;display:flex;justify-content:space-between;align-items:center;gap:40px}.plan-hero h1{margin:18px 0 20px;font:400 clamp(58px,7vw,100px)/.88 Georgia,serif;letter-spacing:-.06em}.plan-hero h1 em{color:#497074;font-style:normal}.plan-hero p{max-width:620px;color:var(--text-muted);font-size:17px;line-height:1.6}.plan-hero__mark{width:150px;height:150px;display:grid;place-items:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);font-size:76px;transform:rotate(45deg)}.plan-toolbar{display:flex;justify-content:space-between;align-items:center;padding:18px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);color:var(--text-muted);font-size:13px}.plan-toolbar>div{display:flex;gap:8px}.plan-content{padding:30px 0 100px}.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:14px}.plan-card{display:flex;flex-direction:column;gap:8px;min-height:220px;padding:22px;border:1px solid var(--border-soft);border-radius:15px;background:var(--surface);box-shadow:0 12px 30px var(--shadow-color)}.plan-card h2{margin:0;font:400 27px Georgia,serif}.plan-card>strong{font:400 22px Georgia,serif;color:var(--success)}.plan-card p{margin:5px 0 0;color:var(--text-muted);font-size:13px}.plan-card small{color:var(--text-subtle);font-size:11px}.plan-card__actions{display:flex;align-items:center;gap:15px;margin-top:auto}.plan-card__actions a{color:var(--text);font-size:13px;text-underline-offset:3px}.plan-card__actions button{border:0;background:none;color:var(--text-muted);font-size:12px;text-decoration:underline;text-underline-offset:3px}.plan-content>.state[hidden]{display:none!important}@media(max-width:720px){.plan-hero{padding:36px 0 25px}.plan-hero__mark{width:90px;height:90px;font-size:46px}.plan-toolbar{display:block}.plan-toolbar>div{margin-top:12px;flex-wrap:wrap}.plan-content{padding-top:20px}}@media print{ @page{size:auto;margin:12mm} .topbar,.alpha-banner,footer,.plan-hero__mark,.plan-toolbar button,.plan-card__actions button{display:none!important}.plan-hero{padding:0 0 20px}.plan-content{padding-top:0}.plan-card{break-inside:avoid;box-shadow:none}}
.plan-toolbar>div{align-items:center;flex-wrap:wrap}.plan-toolbar [data-plan-share-status]{color:var(--text-muted);font-size:11px}
@media(max-width:900px){.plan-hero__mark{display:none}}
+12
View File
@@ -0,0 +1,12 @@
.query-summary{display:flex;justify-content:space-between;align-items:end;gap:24px;padding-top:20px}
.query-summary h2{margin:5px 0 4px;font:400 27px Georgia,serif;letter-spacing:-.035em}
.query-summary p{margin:0;color:var(--text-muted);font-size:13px}
.query-summary__filters{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px}
.query-summary__filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}
.query-summary__filters a{margin-left:4px;color:#4d625e;font-size:13px;text-underline-offset:3px}
.detail-action-heading{margin:27px 0 8px;font:400 24px Georgia,serif;color:var(--success)}
.tackle-card-passport{grid-column:1/-1;min-width:0}.tackle-card-passport .data-passport{margin-top:14px}
.spot-hero__actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.spot-hero__actions small{color:#a9b8b5;font-size:11px}.plan-save[data-saved="true"]{background:var(--lime);color:var(--deep)}
.card-cta{display:inline-flex;align-items:center;gap:8px;margin-top:16px;color:var(--text);font-size:12px;font-weight:750;text-underline-offset:3px}.card-cta span{color:var(--success);font-size:17px;line-height:1}
@media(max-width:720px){.spot-hero__actions{align-items:flex-start;flex-direction:column}.spot-hero__actions small{min-height:15px}}
@media(max-width:720px){.query-summary{display:block;padding:16px 14px 0;width:100%}.query-summary h2{font-size:23px}.query-summary__filters{justify-content:flex-start;flex-wrap:nowrap;overflow-x:auto;padding-top:11px;scroll-snap-type:x proximity;scrollbar-width:none}.query-summary__filters::-webkit-scrollbar{display:none}.query-summary__filters span{flex:0 0 auto;scroll-snap-align:start}.query-summary__filters a{position:sticky;right:0;flex:0 0 auto;padding:6px 10px;background:var(--paper)}}
+13 -6
View File
@@ -6,7 +6,7 @@
--surface-control: light-dark(#f3f6f1, #0a1d1f);
--text-primary: light-dark(#092226, #edf5ef);
--text-secondary: light-dark(#526662, #b8c7c3);
--text-tertiary: light-dark(#647572, #93a7a2);
--text-tertiary: light-dark(#5b6b68, #93a7a2);
--text-on-dark: #f5f8f3;
--border-strong: light-dark(#cbd6ce, #3a5658);
--border-subtle: light-dark(#d6dfd7, #294244);
@@ -39,11 +39,11 @@
--status-warning: light-dark(#9a741d, #e8c768);
--status-changed: light-dark(#a44236, #ff9587);
--source-official: light-dark(#815713, #e3b65b);
--source-rf4db: light-dark(#286581, #74bee3);
--source-rf4db: light-dark(#245d78, #74bee3);
--source-rf4stat: light-dark(#60499a, #b7a2ed);
--source-rf4map: light-dark(#2f704f, #79c79e);
--source-rf4map: light-dark(#276044, #79c79e);
--source-rf4posts: light-dark(#984a33, #ee9a7e);
--source-players: light-dark(#526b1c, #add066);
--source-players: light-dark(#4b6419, #add066);
--graphic-line: light-dark(#3f7779, #79b5b8);
--graphic-grid: light-dark(#dce5de, #294244);
--tackle-orange: light-dark(#b96b27, #f0a55f);
@@ -76,6 +76,7 @@
.active-filters span, .spot-card, .record-table, .report-form,
.periods, .detail-grid aside, .screenshot-retry, .moderation-card,
.moderation-bulk, [data-external-bulk],
.admin-kpis > a, .admin-kpis > article, .admin-dashboard-grid > section,
.source-health-grid article, .state, .load-more, .signal-more {
background: var(--surface-elevated);
@@ -88,7 +89,8 @@
.spot-card:hover { background: var(--surface-raised); border-color: var(--border-strong); box-shadow: 0 18px 40px var(--shadow-color); }
.report-form input, .report-form textarea, .report-form select,
.moderation-actions textarea { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
.moderation-actions textarea, .moderation-bulk textarea, .moderation-bulk select,
[data-external-bulk] textarea, [data-external-bulk] select { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
.record-head, .no-proof { background: var(--surface-raised); }
.record-row, .periods > div, .catch-list article, .detail-grid aside li,
.bait-line, .spot-stats, .moderation-summary dl div,
@@ -127,6 +129,11 @@
.timeline-chart { border-color: var(--border-strong); background: repeating-linear-gradient(to top, transparent 0, transparent 35px, var(--border-subtle) 36px); }
.timeline-slot strong { color: var(--text-primary); }
.timeline-slot small, .activity-legend { color: var(--text-tertiary); }
.spot-hero .eyebrow { color: var(--text-on-dark); }
.activity-legend { color: var(--text-secondary); }
.catch-list .tackle-label > span { color: var(--text-secondary); }
.catch-list span { color: var(--text-secondary); }
.catch-list .source-chip > span { color: var(--chip) !important; }
.catalog-hero { border-color: var(--border-strong); }
.catalog-hero p, .catalog-results aside a, .catalog-grid span { color: var(--text-secondary); }
@@ -185,7 +192,7 @@
@media (forced-colors: active) {
.theme-switcher, .theme-switcher button, .spot-card, .record-table,
.report-form, .moderation-card, .catalog-grid > a, .data-passport,
.report-form, .moderation-card, .moderation-bulk, [data-external-bulk], .catalog-grid > a, .data-passport,
.source-chip, .notice, .state { border: 1px solid CanvasText; }
.theme-switcher button[aria-pressed="true"] { background: Highlight; color: HighlightText; }
.source-chip i, .health-state i, .quality-chip i, .activity-pill i { forced-color-adjust: none; }
+51 -1
View File
@@ -2,7 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
for (const path of [
"/", "/records", "/report", "/rules", "/privacy",
"/", "/records", "/report", "/tackle", "/tackle/analytics", "/tackle/items/00000000-0000-0000-0000-000000000000", "/rules", "/privacy",
"/admin", "/admin/moderation", "/admin/external-sources", "/admin/media",
]) {
test(`${path} has no serious accessibility violations`, async ({ page }) => {
@@ -11,3 +11,53 @@ for (const path of [
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
});
}
test("admin keyboard navigation keeps focus out of the document body", async ({ page }) => {
await page.goto("/admin");
await page.keyboard.press("Tab");
await expect(page.locator(".skip-link")).toBeFocused();
const focusedLabels: string[] = [];
for (let index = 0; index < 20; index += 1) {
await page.keyboard.press("Tab");
focusedLabels.push(await page.evaluate(() => {
const active = document.activeElement;
if (active?.tagName === "BODY") return "BODY";
if (active?.matches(".admin-login input")) return "ADMIN_TOKEN_INPUT";
if (active?.matches(".admin-login button")) return "ADMIN_PANEL_BUTTON";
return (active?.getAttribute("aria-label") || active?.textContent || active?.tagName || "").trim().slice(0, 80);
}));
}
expect(focusedLabels).not.toContain("BODY");
expect(focusedLabels).toContain("ADMIN_TOKEN_INPUT");
expect(focusedLabels).toContain("ADMIN_PANEL_BUTTON");
});
test("print media keeps a light color scheme and removes hero image filters", async ({ page }) => {
await page.emulateMedia({ media: "print" });
await page.goto("/");
const printState = await page.evaluate(() => ({
colorScheme: getComputedStyle(document.documentElement).colorScheme,
heroFilter: document.querySelector(".lake-card img") ? getComputedStyle(document.querySelector(".lake-card img")!).filter : "none",
}));
expect(printState.colorScheme).toBe("light");
expect(printState.heroFilter).toBe("none");
});
test("forced colors keeps theme controls visibly bordered", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/admin");
const forcedColorsState = await page.evaluate(() => {
const control = document.querySelector(".theme-switcher button");
const pressed = document.querySelector(".theme-switcher button[aria-pressed='true']");
return {
borderStyle: control ? getComputedStyle(control).borderStyle : "",
borderWidth: control ? getComputedStyle(control).borderWidth : "",
activeBackground: pressed ? getComputedStyle(pressed).backgroundColor : "",
};
});
expect(forcedColorsState.borderStyle).toBe("solid");
expect(forcedColorsState.borderWidth).toBe("1px");
expect(forcedColorsState.activeBackground).not.toBe("transparent");
});
+12
View File
@@ -0,0 +1,12 @@
import { expect, test } from "@playwright/test";
test("waterbody passport and honest empty tackle catalog are explicit", async ({ page }) => {
await page.goto("/waterbodies/р-вьюнок");
const waterPassport = page.locator('.data-passport[aria-label="Паспорт данных"]');
await expect(waterPassport).toBeVisible();
await expect(waterPassport.getByText("Паспорт данных")).toBeVisible();
await expect(waterPassport.getByText("Свежесть")).toBeVisible();
await page.goto("/tackle");
await expect(page.getByText("Подтверждённых карточек пока нет")).toBeVisible();
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
const unavailableRun = process.env.EXPECT_UNAVAILABLE === "1";
for (const path of [
"/waterbodies",
"/waterbodies/vyunok",
"/waterbodies/vyunok/pike",
"/fish",
"/media?type=waterbody",
"/tackle",
"/tackle/analytics",
"/tackle/items/00000000-0000-0000-0000-000000000000",
"/records",
"/spots/unknown-0x0",
"/report",
"/status",
]) {
test(`${path} exposes a cache-safe unavailable state`, async ({ page }) => {
test.skip(!unavailableRun, "Run with EXPECT_UNAVAILABLE=1 and an API-unavailable web process.");
const response = await page.goto(path);
expect(response?.status(), `${path} status`).toBe(503);
expect(response?.headers()["retry-after"], `${path} retry header`).toBe("60");
expect(response?.headers()["cache-control"], `${path} cache header`).toBe("no-store");
await expect(page.getByRole("alert")).toContainText(/временно недоступ|недоступ/i);
await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex, nofollow");
await expect(page.locator("body")).not.toContainText(/Traceback|localhost:8000|API \d+/i);
});
}
@@ -0,0 +1,17 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
for (const path of [
"/waterbodies",
"/waterbodies/р-вьюнок",
"/spots/vyunok-321x654",
"/media",
"/tackle/analytics",
"/tackle/items/00000000-0000-0000-0000-000000000000",
]) {
test(`${path} has no serious accessibility violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze();
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
});
}
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "@playwright/test";
test("activity cards explain freshness, sample size and coordinate precision", async ({ page }) => {
await page.goto("/");
const passport = page.locator(".spot-card .data-passport");
const count = await passport.count();
expect(count).toBeGreaterThan(0);
const first = passport.first();
const firstCard = page.locator(".spot-card").first();
await expect(first).toContainText("Свежесть");
await expect(first).toContainText("Наблюдения");
await expect(first).toContainText("Игроки");
await expect(first).toContainText("Координаты");
await expect(first).toContainText("Период");
await expect(firstCard).toContainText("Открыть точку");
if (await first.locator('[data-passport-status="insufficient"]').count()) {
await expect(first).toContainText("Недостаточно данных");
await expect(first).toContainText("Минимум для рекомендации: 3 наблюдения");
}
});
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test("home focuses the first screen on five results and keeps pagination", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await expect(page.locator(".spot-card")).toHaveCount(5);
await expect(page.getByRole("navigation", { name: "Пагинация" })).toBeVisible();
await expect(page.getByRole("link", { name: "Следующая →" })).toHaveAttribute("href", /offset=5/);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
test("media catalog keeps image provenance and loaded assets healthy", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/media");
await page.waitForLoadState("networkidle");
const state = await page.evaluate(() => ({
images: Array.from(document.images).map(image => ({
alt: image.alt,
complete: image.complete,
naturalWidth: image.naturalWidth,
})),
width: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(state.images.length).toBeGreaterThan(0);
expect(state.images.every(image => image.alt.trim().length > 0)).toBe(true);
expect(state.images.filter(image => image.complete && image.naturalWidth === 0)).toEqual([]);
expect(state.scrollWidth).toBeLessThanOrEqual(state.width);
});
for (const theme of ["light", "dark"] as const) {
test(`media catalog respects ${theme} theme on a narrow viewport`, async ({ page, context }) => {
await context.addCookies([{ name: "rf4-theme", value: theme, url: "http://127.0.0.1:4321" }]);
await page.setViewportSize({ width: 320, height: 800 });
await page.goto("/media");
const state = await page.evaluate(() => ({
theme: document.documentElement.dataset.theme,
width: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(state.theme).toBe(theme);
expect(state.scrollWidth).toBeLessThanOrEqual(state.width);
});
}
for (const type of ["waterbody", "tackle", "reference"]) {
test(`media ${type} section has a stable page state`, async ({ page }) => {
await page.goto(`/media?type=${type}`);
await expect(page.getByRole("heading", { name: "Медиатека" })).toBeVisible();
await expect(page.locator("main")).toBeVisible();
});
}
+115
View File
@@ -0,0 +1,115 @@
import { expect, test } from "@playwright/test";
test("saved spot appears on the plan page and can be removed or cleared", async ({ page }) => {
await page.goto("/spots/vyunok-6331x6332");
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", method: "Спиннинг", retrieve: "равномерная", risk: "Подтверждено", freshness: "2026-09-20T12:55:11.236309Z", confidence: "24" }])));
await page.goto("/plan");
await expect(page.getByRole("heading", { name: "Вьюнок" })).toBeVisible();
await expect(page.getByText("6331:6332")).toBeVisible();
await expect(page.getByText("Спиннинг · равномерная")).toBeVisible();
await expect(page.getByText(/Риск: Подтверждено/)).toBeVisible();
await expect(page.getByText("1 из 5 точек")).toBeVisible();
await page.getByRole("button", { name: "Убрать" }).click();
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
await page.goto("/spots/vyunok-6331x6332");
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", freshness: "", confidence: "" }])));
await page.goto("/plan");
await page.getByRole("button", { name: "Очистить план" }).click();
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
});
test("plan can be restored from a shareable URL", async ({ page }) => {
const shared = encodeURIComponent(JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", freshness: "", confidence: "" }]));
await page.goto(`/plan?plan=${shared}`);
await expect(page.getByRole("heading", { name: "Вьюнок" })).toBeVisible();
await expect(page.getByRole("button", { name: "Поделиться планом" })).toBeVisible();
await expect(page.getByText("1 из 5 точек")).toBeVisible();
});
test("plan caps imported entries, stays printable and fits narrow viewports", async ({ page }) => {
const plan = Array.from({ length: 6 }, (_, index) => ({
key: `/spots/vyunok-6331x633${index + 2}`,
waterbody: "Вьюнок",
coordinates: `6331:633${index + 2}`,
baits: "Тестовая приманка",
freshness: "",
confidence: "",
}));
await page.goto("/plan");
await page.evaluate((value) => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify(value)), plan);
await page.reload();
await expect(page.getByText("5 из 5 точек")).toBeVisible();
await expect(page.locator(".plan-card")).toHaveCount(5);
for (const width of [320, 390]) {
await page.setViewportSize({ width, height: 800 });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, `${width}px plan overflows`).toBeLessThanOrEqual(0);
}
await page.emulateMedia({ media: "print" });
await expect(page.getByRole("button", { name: "Печать / PDF" })).toBeHidden();
await expect(page.locator(".plan-card")).toHaveCount(5);
});
test("plan preserves a real zero confidence and bounds imported text", async ({ page }) => {
await page.goto("/plan");
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{
key: "/spots/" + "x".repeat(600),
waterbody: "Вьюнок",
coordinates: "6331:6332",
baits: "Приманка",
confidence: "0",
risk: "Малая выборка",
method: "Спиннинг",
retrieve: "равномерная",
oversized: "x".repeat(5000),
}, {
key: "/spots/vyunok-7450x7451",
waterbody: "Вьюнок",
coordinates: "7450:7451",
confidence: "999",
}])));
await page.reload();
await expect(page.getByText(/Доверие: 0%/)).toBeVisible();
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("rf4spotter:fishing-plan") || "[]"));
expect(stored[0].key.length).toBeLessThanOrEqual(512);
expect(stored[0].confidence).toBe("0");
expect(stored[1].confidence).toBe("");
expect(stored[0].oversized).toBeUndefined();
});
test("plan import deduplicates the same point before applying the five-item cap", async ({ page }) => {
await page.goto("/plan");
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([
{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", confidence: "0" },
{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", confidence: "80" },
{ key: "/spots/vyunok-7450x7451", waterbody: "Вьюнок", coordinates: "7450:7451", confidence: "40" },
])));
await page.reload();
await expect(page.getByText("2 из 5 точек")).toBeVisible();
await expect(page.locator(".plan-card")).toHaveCount(2);
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("rf4spotter:fishing-plan") || "[]"));
expect(stored.map((item: { key: string }) => item.key)).toEqual([
"/spots/vyunok-6331x6332",
"/spots/vyunok-7450x7451",
]);
});
test("plan explains empty and malformed share states", async ({ page }) => {
await page.goto("/plan");
await page.getByRole("button", { name: "Поделиться планом" }).click();
await expect(page.locator("[data-plan-share-status]")).toHaveText("Сначала добавьте точку в план");
await page.goto("/plan?plan=not-json");
await expect(page.locator("[data-plan-share-status]")).toHaveText("Ссылка плана не распознана");
const emptyPlan = encodeURIComponent(JSON.stringify([]));
await page.goto(`/plan?plan=${emptyPlan}`);
await expect(page.locator("[data-plan-share-status]")).toHaveText("В ссылке нет сохранённых точек");
});
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
test("production bootstrap supports submission and moderation", async ({ page, request }) => {
test.skip(
!process.env.BOOTSTRAP_API_URL || !process.env.BOOTSTRAP_ADMIN_TOKEN,
"requires BOOTSTRAP_API_URL and BOOTSTRAP_ADMIN_TOKEN; run deploy/test-production-bootstrap.sh",
);
const player = `Bootstrap Player ${Date.now()}`;
const x = 7000 + Date.now() % 1000;
const y = x + 1;
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "@playwright/test";
test("home makes the selected query context explicit and shareable", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?waterbody=kuori&fish=lake-trout&hours=72&sort=freshness");
await expect(page.getByRole("heading", { name: "Озёрная форель · Куори" })).toBeVisible();
await expect(page.getByText("72 часа · Сначала свежие")).toBeVisible();
await expect(page.getByRole("link", { name: "Сбросить запрос" })).toHaveAttribute("href", "/#results");
await expect(page.getByLabel("Водоём")).toHaveValue("kuori");
await expect(page.getByLabel("Рыба")).toHaveValue("lake-trout");
await expect(page.getByLabel("Период")).toHaveValue("72");
await expect(page.getByLabel("Сначала")).toHaveValue("freshness");
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
test("empty filtered result offers a clear way back to the full dataset", async ({ page }) => {
await page.goto("/?waterbody=kuori&fish=lake-trout&hours=6&sort=freshness");
const emptyState = page.getByRole("heading", { name: "Пока нет свежих данных" });
if (await emptyState.isVisible()) {
await expect(page.getByRole("link", { name: "Показать все данные" })).toHaveAttribute("href", "/#results");
}
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("spot can be saved to a five-item local fishing plan and survives reload", async ({ page }) => {
await page.goto("/spots/vyunok-6331x6332");
await page.evaluate(() => localStorage.removeItem("rf4spotter:fishing-plan"));
await page.reload();
const save = page.getByRole("button", { name: "Сохранить в план" });
await expect(save).toHaveAttribute("aria-pressed", "false");
await save.click();
await expect(page.getByRole("button", { name: "В плане" })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText("Добавлено в план")).toBeVisible();
await page.reload();
await expect(page.getByRole("button", { name: "В плане" })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "В плане" }).click();
await expect(page.getByRole("button", { name: "Сохранить в план" })).toHaveAttribute("aria-pressed", "false");
});
+3 -12
View File
@@ -1,19 +1,10 @@
import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
const activeSpots = page.locator(".spot-card[data-testid]");
await expect(activeSpots).not.toHaveCount(0);
const activeSpot = activeSpots.first();
await expect(activeSpot.locator(".source-chip")).not.toHaveCount(0);
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
await activeSpot.click();
await page.goto("/spots/vyunok-321x654");
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
await expect(page.getByText("Точность координат:", { exact: false })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
await expect(page.locator(".catch-list .source-chip")).not.toHaveCount(0);
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
});
test("submitted catch appears publicly only after moderation", async ({ page }) => {
@@ -117,7 +108,7 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
test("public pages fit the narrow viewport and expose the skip link", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
for (const path of ["/", "/records", "/report", "/waterbodies", "/status"]) {
for (const path of ["/", "/records", "/report", "/waterbodies", "/tackle", "/status"]) {
await page.goto(path);
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,

Some files were not shown because too many files have changed in this diff Show More