From 3f0a02a9b90cc465e300cbfb016e455ea1bcaacd Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 20 Sep 2026 20:05:50 +0700 Subject: [PATCH] feat: save local fishing plans --- apps/web/src/pages/spots/[id].astro | 26 +++++++++++++++++++++++++- apps/web/src/styles/query-context.css | 2 ++ apps/web/tests/saved-plan.spec.ts | 18 ++++++++++++++++++ docs/ROADMAP.md | 2 +- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 apps/web/tests/saved-plan.spec.ts diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index 29c6d00..4dd24c1 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -35,7 +35,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr {unavailable || !spot ?

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

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

: <> -
{spot.waterbody}

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

{spot.description}

Точность координат: {coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}

+
{spot.waterbody}

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

{spot.description}

Точность координат: {coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}

{spot.catches_24h}за 24 часа
{spot.catches_3d}за 3 дня
{spot.catches_7d}за 7 дней
ТихоЕсть сигналыГорячо
@@ -57,4 +57,28 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr catch { if (status) status.textContent = value; } }); }); + const planStorageKey = "rf4spotter:fishing-plan"; + const readPlan = (): Array> => { + try { const value = JSON.parse(localStorage.getItem(planStorageKey) || "[]"); return Array.isArray(value) ? value : []; } + catch { return []; } + }; + document.querySelectorAll("[data-plan-save]").forEach((button) => { + const key = button.dataset.planKey || ""; + const status = button.parentElement?.querySelector(".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 || "", freshness: button.dataset.planFreshness || "", confidence: button.dataset.planConfidence || "" }); if (status) status.textContent = "Добавлено в план"; } + localStorage.setItem(planStorageKey, JSON.stringify(plan.slice(0, 5))); + sync(); + }); + }); diff --git a/apps/web/src/styles/query-context.css b/apps/web/src/styles/query-context.css index e3fec2e..a1486b2 100644 --- a/apps/web/src/styles/query-context.css +++ b/apps/web/src/styles/query-context.css @@ -6,4 +6,6 @@ .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(--lime)} .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)} +@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)}} diff --git a/apps/web/tests/saved-plan.spec.ts b/apps/web/tests/saved-plan.spec.ts new file mode 100644 index 0000000..ba469a8 --- /dev/null +++ b/apps/web/tests/saved-plan.spec.ts @@ -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"); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8436970..d4f5894 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -96,7 +96,7 @@ - [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная уже сохраняет рыбу, водоём, период и сортировку в shareable URL и после поиска явно показывает контекст запроса, включая мобильный режим; empty-state предлагает вернуться к полному набору данных. Осталось показать 3–5 вариантов с понятным CTA и добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры. - [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness, completeness, confidence/статус, source badges и доступные доменные поля; targeted E2E проверяет публичные пути. Осталось добавить период выборки, конфликт источников и правило минимальной выборки для любых рекомендаций. Нельзя показывать «лучшую точку» или AI-like рекомендацию без объяснения и минимальной выборки. - [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу. -- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** Добавить лёгкий сценарий сравнения и сохранения 3–5 вариантов без обязательного аккаунта: рыба, водоём, координаты, метод, наживка, свежесть, доверие и риск. Acceptance: shareable URL/print-карточка, 320/390 px, восстановление после reload, удаление локального плана без внешнего сервиса. +- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** На detail точки добавлено сохранение до 5 вариантов без аккаунта в localStorage: водоём, координаты, наживка, свежесть и доверие; кнопка имеет `aria-pressed`, сообщает добавление/удаление и восстанавливается после reload. Осталось сделать отдельный экран сравнения, shareable URL/print-карточку и проверить удаление всего плана на 320/390 px. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, не только цветом. Осталось сократить конкурирующие цифры на карточках и провести ручной review на 5 ключевых маршрутах. - [ ] **U07 · UX-приёмка и измерения.** Добавить Playwright-контракты для полного journey, list/map, evidence states, saved plan, stale/blocked/insufficient data и share/print. Зафиксировать baseline Lighthouse/axe/CLS/INP и time-to-first-useful-answer; закрывать U01–U06 только по evidence, screenshots и обратной связи пилота.