diff --git a/apps/web/package.json b/apps/web/package.json index 4b48f65..0138d14 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 8cde298..3f9d931 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -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, }); diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index 52924a7..a17ae1f 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -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; --- {String(item.activity_score).padStart(2,"0")} -
{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}%уверенность
+
{item.waterbody}{level.short}{limited && Данных мало}

{item.fish}

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

{item.explanation}

Работает сейчас{item.best_bait ?? "не указана"}
+
{item.catches}{plural(item.catches, ["улов", "улова", "уловов"])}
{item.unique_players}{plural(item.unique_players, ["игрок", "игрока", "игроков"])}
{kg(item.average_weight_g)}средний вес
{item.confidence_score}%уверенность
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9029703..bfc0924 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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(path: string): Promise { @@ -19,9 +21,3 @@ export async function api(path: string): Promise { if (!response.ok) throw new Error(`API ${response.status}`); return response.json() as Promise; } - -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)} ч назад`; -} diff --git a/apps/web/src/lib/presentation.ts b/apps/web/src/lib/presentation.ts new file mode 100644 index 0000000..939ad07 --- /dev/null +++ b/apps/web/src/lib/presentation.ts @@ -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)} ч назад`; +} diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index c92c48c..ed0fe6f 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -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(`/api/v1/activity?${query}`); } } catch { unavailable = true; } +const leaderLevel = items[0] ? activityLevel(items[0].activity_score) : null; --- -
RF4 Живая карта клёва

Выбирай место,
пока клюёт.

Свежие точки, рабочие приманки и честная оценка данных от игроков.

Туманное озеро на рассвете
Пульс водоёмов{items.length} точки показывают активность
+
RF4 Живая карта клёва

Выбирай место,
пока клюёт.

Свежие точки, рабочие приманки и честная оценка данных от игроков.

Туманное озеро на рассвете
Пульс водоёмов{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность
@@ -28,8 +29,8 @@ try {
-
За выбранный период

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

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

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

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

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

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

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

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

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

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

}
- {items[0] && } +
За выбранный период

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

{items.length} {plural(items.length, ["точка", "точки", "точек"])}
{filterError ?

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

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

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

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

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

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

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

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

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

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

01

Свежесть

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

02

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

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

03

Уверенность

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

diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index 4204539..bb19b3f 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -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(`/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; } +const level = activity ? activityLevel(activity.activity_score) : null; --- ← Все активные точки {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.length ?
{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/tests/smoke.spec.ts b/apps/web/tests/smoke.spec.ts index 19bf2db..ebc926d 100644 --- a/apps/web/tests/smoke.spec.ts +++ b/apps/web/tests/smoke.spec.ts @@ -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); }); diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts new file mode 100644 index 0000000..08f7164 --- /dev/null +++ b/apps/web/tests/unit/presentation.test.ts @@ -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)), + ["точек", "точка", "точки", "точек", "точек", "точка", "точки", "точек"], + ); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 290090e..7dc223b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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-аудита). diff --git a/docs/UI_UX_AUDIT.md b/docs/UI_UX_AUDIT.md index 17a9350..ba56da0 100644 --- a/docs/UI_UX_AUDIT.md +++ b/docs/UI_UX_AUDIT.md @@ -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 точки`.