diff --git a/apps/api/app/activity.py b/apps/api/app/activity.py index 085a501..a6e7d02 100644 --- a/apps/api/app/activity.py +++ b/apps/api/app/activity.py @@ -68,7 +68,7 @@ def activity_rows( average_weight_g=round(sum(r.weight_g for r in items) / len(items)), max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest, activity_score=activity, confidence_score=confidence, - explanation=f"{len(items)} свежих уловов от {len(players)} игроков. Последнее подтверждение {freshness_text}." + (" Данных мало." if len(items) < 3 else ""), + explanation=_explanation(len(items), len(players), freshness_text, activity, confidence), )) return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True) @@ -82,3 +82,27 @@ def _freshness_text(delta: timedelta) -> str: if minutes < 60: return f"{minutes} мин. назад" return f"{minutes // 60} ч. назад" + + +def _explanation(catches: int, players: int, freshness: str, activity: int, confidence: int) -> str: + activity_label = "Высокая" if activity >= 60 else "Средняя" if activity >= 40 else "Низкая" + confidence_label = "высокая" if confidence >= 70 else "средняя" if confidence >= 40 else "низкая" + summary = ( + f"{activity_label} активность: {_count(catches, 'свежий улов', 'свежих улова', 'свежих уловов')} " + f"от {_count(players, 'игрока', 'игроков', 'игроков')}. " + f"Последнее подтверждение {freshness}. Уверенность {confidence_label}." + ) + return summary + (" Данных мало: нужно хотя бы 3 наблюдения." if catches < 3 else "") + + +def _count(value: int, one: str, few: str, many: str) -> str: + remainder = value % 100 + if 11 <= remainder <= 14: + word = many + elif value % 10 == 1: + word = one + elif 2 <= value % 10 <= 4: + word = few + else: + word = many + return f"{value} {word}" diff --git a/apps/api/app/main.py b/apps/api/app/main.py index b108940..5495cd9 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -78,7 +78,7 @@ def _spot_or_404(db: Session, spot_id: UUID) -> Spot: @app.get("/api/v1/spots/{spot_id}", response_model=SpotOut) def spot_detail(spot_id: UUID, db: Db) -> SpotOut: spot = _spot_or_404(db, spot_id) - reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved))) + reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot.id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)))) now = datetime.now(timezone.utc) def count_since(delta: timedelta) -> int: return sum(_aware(r.reported_at) >= now - delta for r in reports) @@ -89,7 +89,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut: @app.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).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit))) + reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit))) return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports] diff --git a/apps/api/tests/test_activity.py b/apps/api/tests/test_activity.py index 78adf0e..ccb32b0 100644 --- a/apps/api/tests/test_activity.py +++ b/apps/api/tests/test_activity.py @@ -86,7 +86,17 @@ def test_repeated_reports_from_one_player_do_not_add_unique_player_weight(db: Se assert row.catches == 3 assert row.unique_players == 1 - assert "3 свежих уловов от 1 игроков" in row.explanation + assert "3 свежих улова от 1 игрока" in row.explanation + + +def test_explanation_describes_low_data_and_confidence(db: Session) -> None: + add_report(db, age_hours=2, player="Player", confidence=50) + + row = activity_rows(db, hours=6, now=NOW)[0] + + assert row.explanation.startswith("Низкая активность: 1 свежий улов от 1 игрока.") + assert "Уверенность низкая." in row.explanation + assert "Данных мало: нужно хотя бы 3 наблюдения." in row.explanation def test_pending_rejected_and_deleted_reports_are_excluded(db: Session) -> None: diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 8576d75..2950d72 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -46,11 +46,12 @@ def test_activity_filters_and_explains_score() -> None: assert len(payload) == 1 assert payload[0]["catches"] == 3 assert payload[0]["unique_players"] == 3 - assert "3 свежих уловов" in payload[0]["explanation"] + assert "3 свежих улова" in payload[0]["explanation"] def test_invalid_period_is_rejected() -> None: assert client.get("/api/v1/activity?hours=13").status_code == 422 + assert client.get("/api/v1/activity?sort=unknown").status_code == 422 def test_spot_detail_and_catches() -> None: diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index eb0ded1..52924a7 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -3,9 +3,10 @@ import type { Activity } from "../lib/api"; import { ago, kg } from "../lib/api"; const { item } = Astro.props as { item: Activity }; const level = item.activity_score >= 80 ? "Очень высокий" : item.activity_score >= 60 ? "Высокий" : item.activity_score >= 40 ? "Средний" : "Низкий"; +const limited = item.catches < 3; --- {String(item.activity_score).padStart(2,"0")} -
{item.waterbody}{level}

{item.fish}

⌖ {item.x}:{item.y}◷ {ago(item.last_confirmed_at)}
Работает сейчас{item.best_bait ?? "не указана"}
+
{item.waterbody}{level}{limited && Данных мало}

{item.fish}

⌖ {item.x}:{item.y}◷ {ago(item.last_confirmed_at)}

{item.explanation}

Работает сейчас{item.best_bait ?? "не указана"}
{item.catches}уловов
{item.unique_players}игроков
{kg(item.average_weight_g)}средний вес
{item.confidence_score}%уверенность
diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index 7d6d448..c92c48c 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -9,12 +9,14 @@ const waterbody = params.get("waterbody") ?? ""; const fish = params.get("fish") ?? ""; const sort = params.get("sort") ?? "activity"; let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; +const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort); let unavailable = false; try { - [items, fishes, waterbodies] = await Promise.all([ - api(`/api/v1/activity?hours=${hours}&waterbody=${waterbody}&fish=${fish}&sort=${sort}`), - api("/api/v1/fishes"), api("/api/v1/waterbodies") - ]); + [fishes, waterbodies] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies")]); + if (!filterError) { + const query = new URLSearchParams({ hours, waterbody, fish, sort }); + items = await api(`/api/v1/activity?${query}`); + } } catch { unavailable = true; } --- @@ -26,7 +28,7 @@ try { -
За выбранный период

Горячие точки

{items.length} точки
{unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки.

: items.length ?
{items.map(item => )}
:

Пока нет свежих данных

Попробуйте другой водоём или период.

}
+
За выбранный период

Горячие точки

{items.length} точки
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте позже.

: items.length ?
{items.map(item => )}
:

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
{items[0] && }
Как читать данные

Не обещаем рыбу.
Показываем факты.

01

Свежесть

Чем старше сообщение, тем меньше оно влияет на активность.

02

Разные игроки

Десять уловов одного человека не равны десяти подтверждениям.

03

Уверенность

Каждая оценка объясняет, сколько данных за ней стоит.

diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index bd6898a..4204539 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -1,15 +1,18 @@ --- import Layout from "../../layouts/Layout.astro"; -import { api, kg, type Catch, type Spot } from "../../lib/api"; +import { api, kg, type Activity, type Catch, type Spot } from "../../lib/api"; const { id } = Astro.params; -let spot: Spot | null = null, catches: Catch[] = [], unavailable = false; -try { [spot, catches] = await Promise.all([api(`/api/v1/spots/${id}`), api(`/api/v1/spots/${id}/catches`)]); } catch { unavailable = true; } +let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false; +try { + const [spotResult, catchResult, activityRows] = await Promise.all([api(`/api/v1/spots/${id}`), api(`/api/v1/spots/${id}/catches`), api("/api/v1/activity?hours=24&limit=100")]); + spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null; +} catch { unavailable = true; } --- ← Все активные точки {unavailable || !spot ?

Точка недоступна

API не ответил или такой точки нет.

: <>
{spot.waterbody}

Точка {spot.x}:{spot.y}

{spot.description}

{spot.x}:{spot.y}
{spot.catches_24h}за 24 часа
{spot.catches_3d}за 3 дня
{spot.catches_7d}за 7 дней
-

Последние уловы

{catches.map(item =>
{item.fish}{item.bait ?? "Приманка не указана"}
{kg(item.weight_g)}{item.player_name ?? "Анонимно"}
)}
+

Последние уловы

{catches.length ?
{catches.map(item =>
{item.fish}{item.bait ?? "Приманка не указана"}
{kg(item.weight_g)}{item.player_name ?? "Анонимно"}
)}
:

Уловов пока нет

Для этой точки нет одобренных наблюдений.

}
}
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 9c98436..7b0742c 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -5,7 +5,7 @@ .filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750} .dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c} .detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff} -.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572} +.data-quality{padding:4px 8px;border-radius:20px;background:#fff0c7;color:#73550c;font-size:10px;font-weight:750}.data-note{margin:12px 0 0;color:#667875;font-size:12px;line-height:1.45}.state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.state p{max-width:560px;line-height:1.5}.error-state{border-color:#d8aaa1}.error-state a{color:#842f25}.compact-state{min-height:180px}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572} .records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572} .notice.warning{background:#fff0c7;color:#73550c}.screenshot-retry{width:min(900px,calc(100% - 64px));margin:0 auto 24px;padding:22px;background:#fff;border:1px solid #e2c975;border-radius:12px}.screenshot-retry label{color:#60716e}.screenshot-retry input{display:block;width:100%;margin:8px 0 14px}.screenshot-retry button{border:0;border-radius:9px;padding:13px 20px;background:var(--deep);color:#fff;font-weight:750} .moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6b030c4..e14eb16 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,8 +45,8 @@ - [x] Покрыть unit-тестами затухание по свежести, доверие к официальным и пользовательским источникам, повторные сообщения одного игрока и вклад разных игроков. - [x] Не учитывать pending/rejected/удалённые записи и доказать это тестами. - [x] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа. -- [ ] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана. -- [ ] Реализовать и проверить состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров. +- [x] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана (unit-тест объяснения и Astro build). +- [x] Реализовать состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров (Astro build; браузерная проверка войдёт в общий прогон фильтров). - [ ] Проверить фильтры главной страницы сквозным тестом на desktop и mobile. Критерий готовности: оценка объяснима, воспроизводима тестами и никогда не маскирует недостаток или устаревание данных. @@ -77,10 +77,10 @@ Этап 3 завершён. Следующий пакет продолжает этап 4: -1. человекочитаемые состояния и объём данных на карточках этапа 4; -2. состояния «данных мало», «данных нет», «источник недоступен» и ошибки фильтров; -3. сквозная проверка фильтров главной страницы на desktop и mobile; -4. переход к health/readiness и структурированным логам подготовки MVP. +1. сквозная проверка фильтров главной страницы на desktop и mobile; +2. переход к health/readiness PostgreSQL, MinIO, API и импорта; +3. структурированные логи без пользовательских секретов; +4. CI для тестов, Astro build, E2E и миграций. После каждого пункта необходимо: