feat: harden fishing plan sharing
This commit is contained in:
@@ -3,7 +3,7 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
---
|
---
|
||||||
<Layout title="Мой план рыбалки — RF4 Spotter" description="Сохранённые точки рыбалки RF4 без аккаунта: координаты, наживки, свежесть и доверие." noindex>
|
<Layout title="Мой план рыбалки — RF4 Spotter" description="Сохранённые точки рыбалки RF4 без аккаунта: координаты, наживки, свежесть и доверие." noindex>
|
||||||
<section class="plan-hero content-grid"><div><span class="eyebrow">Локально на этом устройстве</span><h1>Мой план<br/><em>рыбалки.</em></h1><p>Соберите до пяти точек, сравните их перед выездом и распечатайте список. Данные не отправляются на сервер.</p></div><div class="plan-hero__mark" aria-hidden="true">♢</div></section>
|
<section class="plan-hero content-grid"><div><span class="eyebrow">Локально на этом устройстве</span><h1>Мой план<br/><em>рыбалки.</em></h1><p>Соберите до пяти точек, сравните их перед выездом и распечатайте список. Данные не отправляются на сервер.</p></div><div class="plan-hero__mark" aria-hidden="true">♢</div></section>
|
||||||
<section class="plan-toolbar content-grid" aria-label="Действия с планом"><span data-plan-count>0 из 5 точек</span><div><button data-action="secondary" type="button" data-plan-share>Поделиться планом</button><button data-action="secondary" type="button" data-plan-print>Печать / PDF</button><button data-action="quiet" type="button" data-plan-clear>Очистить план</button><small data-plan-share-status aria-live="polite"></small></div></section>
|
<section class="plan-toolbar content-grid" aria-label="Действия с планом"><span data-plan-count>0 из 5 точек</span><div><button data-action="secondary" type="button" data-plan-share>Поделиться планом</button><button data-action="secondary" type="button" data-plan-copy>Скопировать ссылку</button><button data-action="secondary" type="button" data-plan-print>Печать / PDF</button><button data-action="quiet" type="button" data-plan-clear>Очистить план</button><small data-plan-share-status aria-live="polite"></small></div></section>
|
||||||
<section class="plan-content content-grid" data-plan-page aria-live="polite"><div class="plan-grid" data-plan-list></div><div class="state" data-plan-empty><h2>План пока пуст</h2><p>Сохраните точку на её detail-странице — она появится здесь.</p><a data-action="secondary" href="/">Посмотреть горячие точки</a></div></section>
|
<section class="plan-content content-grid" data-plan-page aria-live="polite"><div class="plan-grid" data-plan-list></div><div class="state" data-plan-empty><h2>План пока пуст</h2><p>Сохраните точку на её detail-странице — она появится здесь.</p><a data-action="secondary" href="/">Посмотреть горячие точки</a></div></section>
|
||||||
</Layout>
|
</Layout>
|
||||||
<script>
|
<script>
|
||||||
@@ -12,6 +12,10 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
const empty = document.querySelector<HTMLElement>("[data-plan-empty]");
|
const empty = document.querySelector<HTMLElement>("[data-plan-empty]");
|
||||||
const count = document.querySelector<HTMLElement>("[data-plan-count]");
|
const count = document.querySelector<HTMLElement>("[data-plan-count]");
|
||||||
const shareStatus = document.querySelector<HTMLElement>("[data-plan-share-status]");
|
const shareStatus = document.querySelector<HTMLElement>("[data-plan-share-status]");
|
||||||
|
const shareButton = document.querySelector<HTMLButtonElement>("[data-plan-share]");
|
||||||
|
const copyButton = document.querySelector<HTMLButtonElement>("[data-plan-copy]");
|
||||||
|
const printButton = document.querySelector<HTMLButtonElement>("[data-plan-print]");
|
||||||
|
const clearButton = document.querySelector<HTMLButtonElement>("[data-plan-clear]");
|
||||||
const planFieldLimits: Record<string, number> = { key: 512, waterbody: 160, coordinates: 80, baits: 1000, method: 120, retrieve: 160, risk: 160, freshness: 40, confidence: 8 };
|
const planFieldLimits: Record<string, number> = { 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 planText = (key: string, value: unknown) => typeof value === "string" ? value.slice(0, planFieldLimits[key] ?? 240) : "";
|
||||||
const normalisePlan = (value: unknown): Array<Record<string, string>> => {
|
const normalisePlan = (value: unknown): Array<Record<string, string>> => {
|
||||||
@@ -52,6 +56,7 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
empty.hidden = plan.length > 0;
|
empty.hidden = plan.length > 0;
|
||||||
if (count) count.textContent = `${plan.length} из 5 точек`;
|
if (count) count.textContent = `${plan.length} из 5 точек`;
|
||||||
|
[shareButton, copyButton, printButton, clearButton].forEach(button => { if (button) button.disabled = plan.length === 0; });
|
||||||
plan.forEach(item => {
|
plan.forEach(item => {
|
||||||
const card = document.createElement("article"); card.className = "plan-card";
|
const card = document.createElement("article"); card.className = "plan-card";
|
||||||
const heading = document.createElement("h2"); heading.textContent = item.waterbody || "Водоём не указан";
|
const heading = document.createElement("h2"); heading.textContent = item.waterbody || "Водоём не указан";
|
||||||
@@ -65,17 +70,39 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
card.append(heading, coordinate, meta, approach, facts, actions); list.append(card);
|
card.append(heading, coordinate, meta, approach, facts, actions); list.append(card);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
document.querySelector<HTMLButtonElement>("[data-plan-share]")?.addEventListener("click", async () => {
|
const planUrl = () => {
|
||||||
const plan = readPlan();
|
const plan = readPlan();
|
||||||
if (!plan.length) { if (shareStatus) shareStatus.textContent = "Сначала добавьте точку в план"; return; }
|
|
||||||
const url = new URL(location.href); url.search = ""; url.searchParams.set("plan", JSON.stringify(plan));
|
const url = new URL(location.href); url.search = ""; url.searchParams.set("plan", JSON.stringify(plan));
|
||||||
|
return { plan, value: url.toString() };
|
||||||
|
};
|
||||||
|
const copyText = async (value: string) => {
|
||||||
|
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; }
|
||||||
|
const field = document.createElement("textarea");
|
||||||
|
field.value = value; field.setAttribute("readonly", ""); field.style.position = "fixed"; field.style.opacity = "0";
|
||||||
|
document.body.append(field); field.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
field.remove();
|
||||||
|
if (!copied) throw new Error("Clipboard is unavailable");
|
||||||
|
};
|
||||||
|
const copyPlanLink = async () => {
|
||||||
|
const { plan, value } = planUrl();
|
||||||
|
if (!plan.length) { if (shareStatus) shareStatus.textContent = "Сначала добавьте точку в план"; return; }
|
||||||
|
await copyText(value);
|
||||||
|
if (shareStatus) shareStatus.textContent = "Ссылка скопирована";
|
||||||
|
};
|
||||||
|
shareButton?.addEventListener("click", async () => {
|
||||||
|
const { plan, value } = planUrl();
|
||||||
|
if (!plan.length) { if (shareStatus) shareStatus.textContent = "Сначала добавьте точку в план"; return; }
|
||||||
try {
|
try {
|
||||||
if (navigator.share) await navigator.share({ title: "Мой план рыбалки RF4", url: url.toString() });
|
if (navigator.share) { await navigator.share({ title: "Мой план рыбалки RF4", url: value }); if (shareStatus) shareStatus.textContent = "План отправлен"; }
|
||||||
else await navigator.clipboard.writeText(url.toString());
|
else await copyPlanLink();
|
||||||
if (shareStatus) shareStatus.textContent = "Ссылка готова";
|
} catch (error) {
|
||||||
} catch { if (shareStatus) shareStatus.textContent = "Скопируйте ссылку из адресной строки"; }
|
if (error instanceof DOMException && error.name === "AbortError") { if (shareStatus) shareStatus.textContent = "Поделиться отменено"; return; }
|
||||||
|
if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.querySelector<HTMLButtonElement>("[data-plan-print]")?.addEventListener("click", () => window.print());
|
copyButton?.addEventListener("click", async () => { try { await copyPlanLink(); } catch { if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку"; } });
|
||||||
document.querySelector<HTMLButtonElement>("[data-plan-clear]")?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); render(); });
|
printButton?.addEventListener("click", () => window.print());
|
||||||
|
clearButton?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); if (shareStatus) shareStatus.textContent = "План очищен"; render(); });
|
||||||
render();
|
render();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@
|
|||||||
- [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Добавлен URL-сохраняющий режим `view=map` с отдельным mobile-friendly действием: он показывает только подтверждённые `x:y` на честной координатной схеме и ведёт в detail точки, без имитации геометрии водоёма. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры.
|
- [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Добавлен URL-сохраняющий режим `view=map` с отдельным mobile-friendly действием: он показывает только подтверждённые `x:y` на честной координатной схеме и ведёт в detail точки, без имитации геометрии водоёма. 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-регрессией. Все публичные provenance links проходят credential-free HTTP(S) allowlist.
|
- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Источники координат доступны в раскрываемом блоке паспорта, тогда как точность остаётся видимой в основном ответе. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников; единый `passportDisplayStatus` дополнительно закрепляет приоритет `conflict` → `stale` → исходный статус unit-регрессией. Все публичные provenance links проходят credential-free HTTP(S) allowlist.
|
||||||
- [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Источники координат в activity/spot evidence-паспорте и многокомпонентный комплект снастей в списке уловов раскрываются по запросу, не скрывая основную точность, статус и источник; canonical-компоненты сохраняют переходы в каталог. Режим `view=map` использует единый URL-контракт, на mobile открывается отдельным действием и прямо маркирован как координатная схема, пока геометрия водоёма не подтверждена. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу.
|
- [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Источники координат в activity/spot evidence-паспорте и многокомпонентный комплект снастей в списке уловов раскрываются по запросу, не скрывая основную точность, статус и источник; canonical-компоненты сохраняют переходы в каталог. Режим `view=map` использует единый URL-контракт, на mobile открывается отдельным действием и прямо маркирован как координатная схема, пока геометрия водоёма не подтверждена. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу.
|
||||||
- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 уникальных локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback и явно сообщает, если план пуст или share-ссылка повреждена. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; импорт дедуплицирует точки, нормализует длины локальных текстовых полей, принимает confidence только в диапазоне `0..100` и не теряет реальное значение `0`, а карточка плана сравнивает подтверждённые метод/проводку и явно показывает риск по выборке/уверенности.
|
- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 уникальных локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback, отдельное копирование ссылки и явно сообщает об успехе, отмене, пустом плане или повреждённой share-ссылке. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; импорт дедуплицирует точки, нормализует длины локальных текстовых полей, принимает confidence только в диапазоне `0..100` и не теряет реальное значение `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).
|
- [ ] **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 теперь проверяет `252` комбинации, включая valid/error spot, plan и tackle analytics; representative review зафиксирован в `docs/ux-reference-review-2026-09-21.md`. Локальный Lighthouse baseline обновлён: performance 98 и accessibility 100. Остаётся получить production Lighthouse/CLS/INP/time-to-first-useful-answer после пилота и принять решение о долговременном хранении screenshots.
|
- [ ] **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 теперь проверяет `252` комбинации, включая valid/error spot, plan и tackle analytics; representative review зафиксирован в `docs/ux-reference-review-2026-09-21.md`. Локальный Lighthouse baseline обновлён: performance 98 и accessibility 100. Остаётся получить production Lighthouse/CLS/INP/time-to-first-useful-answer после пилота и принять решение о долговременном хранении screenshots.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user