fix: unify activity labels and Russian plurals

This commit is contained in:
ik
2026-09-06 13:31:44 +07:00
parent a4b51027e2
commit 366ff83a60
11 changed files with 72 additions and 27 deletions
+1
View File
@@ -8,6 +8,7 @@
"build": "astro check && astro build",
"start": "node ./dist/server/entry.mjs",
"check": "astro check",
"test:unit": "node --test tests/unit/*.test.ts",
"test:e2e": "playwright test"
},
"dependencies": {
+1
View File
@@ -2,6 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
testIgnore: "**/unit/**",
use: { baseURL: process.env.WEB_URL || "http://127.0.0.1:4321" },
retries: 0,
});
+4 -4
View File
@@ -1,12 +1,12 @@
---
import type { Activity } from "../lib/api";
import { ago, kg } from "../lib/api";
import { activityLevel, ago, kg, plural } 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 level = activityLevel(item.activity_score);
const limited = item.catches < 3;
---
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill"><i></i>{level}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span>⌖ {item.x}:{item.y}</span><span>◷ {ago(item.last_confirmed_at)}</span></div><p class="data-note">{item.explanation}</p><div class="bait-line"><span class="lure-dot"></span><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>уловов</span></div><div><strong>{item.unique_players}</strong><span>игроков</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"></span>
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span>⌖ {item.x}:{item.y}</span><span>◷ {ago(item.last_confirmed_at)}</span></div><p class="data-note">{item.explanation}</p><div class="bait-line"><span class="lure-dot"></span><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{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"></span>
</a>
+2 -6
View File
@@ -12,6 +12,8 @@ export type DictionaryItem = { id: string; slug: string; name_ru: string };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
export { activityLevel, ago, kg, plural } from "./presentation";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export async function api<T>(path: string): Promise<T> {
@@ -19,9 +21,3 @@ export async function api<T>(path: string): Promise<T> {
if (!response.ok) throw new Error(`API ${response.status}`);
return response.json() as Promise<T>;
}
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
export function ago(value: string) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
}
+22
View File
@@ -0,0 +1,22 @@
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
export function plural(number: number, forms: readonly [string, string, string]) {
const absolute = Math.abs(number) % 100;
const last = absolute % 10;
if (absolute > 10 && absolute < 20) return forms[2];
if (last === 1) return forms[0];
if (last >= 2 && last <= 4) return forms[1];
return forms[2];
}
export function activityLevel(score: number) {
if (score >= 80) return { short: "Очень высокий", description: "Очень высокая активность" };
if (score >= 60) return { short: "Высокий", description: "Высокая активность" };
if (score >= 40) return { short: "Средний", description: "Средняя активность" };
return { short: "Низкий", description: "Низкая активность" };
}
export function ago(value: string) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
}
+5 -4
View File
@@ -1,7 +1,7 @@
---
import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro";
import { ago, api, kg, type Activity, type DictionaryItem } from "../lib/api";
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem } from "../lib/api";
const params = Astro.url.searchParams;
const hours = params.get("hours") ?? "24";
@@ -18,9 +18,10 @@ try {
items = await api<Activity[]>(`/api/v1/activity?${query}`);
}
} catch { unavailable = true; }
const leaderLevel = items[0] ? activityLevel(items[0].activity_score) : null;
---
<Layout title="Что клюёт сейчас — RF4 Spotter">
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} точки показывают активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
<section class="filters-wrap"><form class="filters content-grid" method="get">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
@@ -28,8 +29,8 @@ try {
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
<button>⌕ Найти клёв</button>
</form></section>
<section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} точки</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
{items[0] && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку">↗</a></div><div class="detail-score"><div class="score-ring" style={`--score:${items[0].activity_score * 3.6}deg`}><div><strong>{items[0].activity_score}</strong><span>из 100</span></div></div><div><span>Индекс активности</span><strong>{items[0].activity_score >= 70 ? "Клёв отличный" : "Клёв хороший"}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span>◎</span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span>♟</span><small>Игроков</small><strong>{items[0].unique_players}</strong></div><div><span>◷</span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span>⌁</span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><i></i><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} уловов</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
<section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} {plural(items.length, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку">↗</a></div><div class="detail-score"><div class="score-ring" style={`--score:${items[0].activity_score * 3.6}deg`}><div><strong>{items[0].activity_score}</strong><span>из 100</span></div></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span>◎</span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span>♟</span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span>◷</span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span>⌁</span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><i></i><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section>
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
</Layout>
+3 -2
View File
@@ -1,18 +1,19 @@
---
import Layout from "../../layouts/Layout.astro";
import { api, kg, type Activity, type Catch, type Spot } from "../../lib/api";
import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
try {
const [spotResult, catchResult, activityRows] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
} catch { unavailable = true; }
const level = activity ? activityLevel(activity.activity_score) : null;
---
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
<a class="back" href="/">← Все активные точки</a>
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity ? <><h2>{activity.activity_score} / 100</h2><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} наблюдениях от {activity.unique_players} игроков. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
</>}
</Layout>
+10 -6
View File
@@ -3,11 +3,15 @@ import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
const seedSpot = page.getByTestId("spot-110-103");
await expect(seedSpot.getByRole("heading", { name: "Щука" })).toBeVisible();
await seedSpot.click();
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
const activeSpots = page.locator(".spot-card[data-testid]");
await expect(activeSpots).not.toHaveCount(0);
const activeSpot = activeSpots.first();
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
await activeSpot.click();
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
});
test("submitted catch appears publicly only after moderation", async ({ page }) => {
@@ -49,8 +53,8 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
await page.getByLabel("Сначала").selectOption("freshness");
await page.getByRole("button", { name: "⌕ Найти клёв" }).click();
await expect(page).toHaveURL(/waterbody=kuori.*fish=lake-trout.*hours=72.*sort=freshness/);
await expect(page.getByTestId("spot-85-92")).toBeVisible();
await expect(page.getByTestId("spot-110-103")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Горячие точки" })).toBeVisible();
await expect(page.locator(".spot-list, .state")).toBeVisible();
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
});
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { activityLevel, plural } from "../../src/lib/presentation.ts";
test("activity levels share one complete 0-100 scale", () => {
assert.deepEqual(
[0, 39, 40, 59, 60, 79, 80, 100].map(score => activityLevel(score).short),
["Низкий", "Низкий", "Средний", "Средний", "Высокий", "Высокий", "Очень высокий", "Очень высокий"],
);
});
test("Russian plural forms cover common and exceptional endings", () => {
const forms = ["точка", "точки", "точек"] as const;
assert.deepEqual(
[0, 1, 2, 5, 11, 21, 22, 25].map(number => plural(number, forms)),
["точек", "точка", "точки", "точек", "точек", "точка", "точки", "точек"],
);
});
+1 -1
View File
@@ -70,7 +70,7 @@
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
- [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`).
- [ ] Исправить единую шкалу активности и русские числительные во всех публичных представлениях (пакет A UI/UX-аудита).
- [x] Исправить единую шкалу активности и русские числительные во всех публичных представлениях (пакет A UI/UX-аудита; unit-тест границ и E2E согласованности).
- [ ] Сократить мобильный путь до результатов и улучшить фильтры (пакет B UI/UX-аудита).
- [ ] Упростить форму улова, добавить помощь форматов и сохранение значений при ошибке (пакет C UI/UX-аудита).
- [ ] Заменить slug-фильтры и улучшить мобильное представление рекордов/detail (пакет D UI/UX-аудита).
+4 -4
View File
@@ -36,10 +36,10 @@
### Пакет A — достоверность интерфейса
- [ ] Вынести шкалу активности и пользовательские формулировки в общий helper.
- [ ] Устранить противоречие карточки, sidebar и detail для всех диапазонов 0–100.
- [ ] Добавить helper русских числительных и unit-тесты граничных значений.
- [ ] Проверить тексты состояний «мало данных», «нет данных», «источник недоступен» на непротиворечивость.
- [x] Вынести шкалу активности и пользовательские формулировки в общий helper.
- [x] Устранить противоречие карточки, sidebar и detail для всех диапазонов 0–100.
- [x] Добавить helper русских числительных и unit-тесты граничных значений.
- [x] Проверить тексты состояний «мало данных», «нет данных», «источник недоступен» на непротиворечивость.
Критерий: одно значение во всех представлениях имеет одинаковый уровень и объяснение; в UI нет строк вида `1 точки`.