diff --git a/apps/web/src/pages/plan.astro b/apps/web/src/pages/plan.astro index 70bbcaf..4ec4580 100644 --- a/apps/web/src/pages/plan.astro +++ b/apps/web/src/pages/plan.astro @@ -12,11 +12,13 @@ import Layout from "../layouts/Layout.astro"; const empty = document.querySelector("[data-plan-empty]"); const count = document.querySelector("[data-plan-count]"); const shareStatus = document.querySelector("[data-plan-share-status]"); + const planFieldLimits: Record = { 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> => { if (!Array.isArray(value)) return []; return value.filter(item => item && typeof item === "object" && typeof item.key === "string" && item.key.startsWith("/spots/")).slice(0, 5).map(item => { const source = item as Record; - return Object.fromEntries(["key", "waterbody", "coordinates", "baits", "method", "retrieve", "risk", "freshness", "confidence"].map(key => [key, typeof source[key] === "string" ? source[key] : ""])); + return Object.fromEntries(["key", "waterbody", "coordinates", "baits", "method", "retrieve", "risk", "freshness", "confidence"].map(key => [key, planText(key, source[key])])); }); }; const readPlan = (): Array> => { @@ -37,7 +39,7 @@ import Layout from "../layouts/Layout.astro"; 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 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); diff --git a/apps/web/tests/plan-page.spec.ts b/apps/web/tests/plan-page.spec.ts index 18a49d9..59bb276 100644 --- a/apps/web/tests/plan-page.spec.ts +++ b/apps/web/tests/plan-page.spec.ts @@ -54,3 +54,25 @@ test("plan caps imported entries, stays printable and fits narrow viewports", as 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), + }]))); + 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[0].oversized).toBeUndefined(); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c035042..0c440a9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -96,7 +96,7 @@ - [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Осталось добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры. - [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников; единый `passportDisplayStatus` дополнительно закрепляет приоритет `conflict` → `stale` → исходный статус unit-регрессией. - [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу. -- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; карточка плана теперь сравнивает подтверждённые метод/проводку и явно показывает риск по выборке/уверенности. +- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; импорт нормализует длины локальных текстовых полей, не теряет реальное значение confidence `0`, а карточка плана сравнивает подтверждённые метод/проводку и явно показывает риск по выборке/уверенности. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, provenance и качество собраны в общем паспорте данных, а подтверждённые метод/проводка видны рядом с последними уловами; статусы дополнительно передаются текстом, малая выборка не маркируется как готовая рекомендация, а дублирующие catches/players/confidence убраны из вторичной колонки activity-карточки. Mobile route-contract проверяет первый ответ и действие на home, spot, waterbody, plan и tackle, а task regression требует source/evidence-поля; остаётся ручной review визуальных деталей по [ux-task-review-2026-09-21.md](ux-task-review-2026-09-21.md). - [ ] **U07 · UX-приёмка и измерения.** Playwright-контракты уже покрывают query journey, сквозной task journey, evidence states, saved plan, share/print, accessibility и visual matrix; unit-контракт добавил детерминированные stale- и blocked-подписи, а insufficient-data теперь отделён от incomplete в публичном паспорте. `/status` явно сообщает, что при ограничении источника опубликованные данные сохранены. Visual-matrix regression теперь проверяет `204` комбинации, включая spot detail, plan и tackle analytics; representative review зафиксирован в `docs/ux-reference-review-2026-09-21.md`. Остаётся получить production Lighthouse/CLS/INP/time-to-first-useful-answer после пилота и принять решение о долговременном хранении screenshots.