feat: make fishing plan imports explicit
This commit is contained in:
@@ -2,9 +2,10 @@
|
|||||||
import Layout from "../layouts/Layout.astro";
|
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>Соберите до пяти точек, сравните их перед выездом и распечатайте список. План хранится на устройстве; ссылка содержит только snapshot для передачи.</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-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-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>
|
||||||
|
<dialog data-plan-import aria-labelledby="plan-import-title"><form method="dialog"><h2 id="plan-import-title">Импортировать план?</h2><p data-plan-import-summary></p><div class="plan-import-actions"><button data-action="secondary" value="cancel">Отмена</button><button data-action="secondary" value="merge">Добавить к текущему</button><button data-action="primary" value="replace">Заменить текущий</button></div></form></dialog>
|
||||||
</Layout>
|
</Layout>
|
||||||
<script>
|
<script>
|
||||||
const planStorageKey = "rf4spotter:fishing-plan";
|
const planStorageKey = "rf4spotter:fishing-plan";
|
||||||
@@ -16,6 +17,8 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
const copyButton = document.querySelector<HTMLButtonElement>("[data-plan-copy]");
|
const copyButton = document.querySelector<HTMLButtonElement>("[data-plan-copy]");
|
||||||
const printButton = document.querySelector<HTMLButtonElement>("[data-plan-print]");
|
const printButton = document.querySelector<HTMLButtonElement>("[data-plan-print]");
|
||||||
const clearButton = document.querySelector<HTMLButtonElement>("[data-plan-clear]");
|
const clearButton = document.querySelector<HTMLButtonElement>("[data-plan-clear]");
|
||||||
|
const importDialog = document.querySelector<HTMLDialogElement>("[data-plan-import]");
|
||||||
|
const importSummary = document.querySelector<HTMLElement>("[data-plan-import-summary]");
|
||||||
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>> => {
|
||||||
@@ -37,16 +40,32 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
const raw = localStorage.getItem(planStorageKey) || "[]";
|
const raw = localStorage.getItem(planStorageKey) || "[]";
|
||||||
const normalised = normalisePlan(JSON.parse(raw));
|
const normalised = normalisePlan(JSON.parse(raw));
|
||||||
const serialised = JSON.stringify(normalised);
|
const serialised = JSON.stringify(normalised);
|
||||||
if (serialised !== raw) localStorage.setItem(planStorageKey, serialised);
|
if (serialised !== raw) { try { localStorage.setItem(planStorageKey, serialised); } catch { if (shareStatus) shareStatus.textContent = "Хранилище браузера недоступно"; } }
|
||||||
return normalised;
|
return normalised;
|
||||||
}
|
}
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
};
|
};
|
||||||
|
const writePlan = (value: Array<Record<string, string>>) => {
|
||||||
|
try { localStorage.setItem(planStorageKey, JSON.stringify(normalisePlan(value))); return true; }
|
||||||
|
catch { if (shareStatus) shareStatus.textContent = "Не удалось сохранить план в браузере"; return false; }
|
||||||
|
};
|
||||||
|
const freshnessLabel = (value: string) => {
|
||||||
|
if (!value) return "Свежесть не указана";
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (!Number.isFinite(timestamp)) return "Свежесть не распознана";
|
||||||
|
return Date.now() - timestamp > 24 * 60 * 60 * 1000 ? "Устарело" : "Свежо";
|
||||||
|
};
|
||||||
const shared = new URLSearchParams(location.search).get("plan");
|
const shared = new URLSearchParams(location.search).get("plan");
|
||||||
|
let pendingImport: Array<Record<string, string>> | null = null;
|
||||||
if (shared) {
|
if (shared) {
|
||||||
|
history.replaceState(null, "", `${location.pathname}${location.hash}`);
|
||||||
try {
|
try {
|
||||||
const imported = normalisePlan(JSON.parse(shared));
|
const imported = normalisePlan(JSON.parse(shared));
|
||||||
if (imported.length) localStorage.setItem(planStorageKey, JSON.stringify(imported));
|
if (imported.length) {
|
||||||
|
pendingImport = imported;
|
||||||
|
if (importSummary) importSummary.textContent = `В ссылке ${imported.length} ${imported.length === 1 ? "точка" : "точки"}. Текущий план не изменён.`;
|
||||||
|
if (importDialog?.showModal) importDialog.showModal();
|
||||||
|
}
|
||||||
else if (shareStatus) shareStatus.textContent = "В ссылке нет сохранённых точек";
|
else if (shareStatus) shareStatus.textContent = "В ссылке нет сохранённых точек";
|
||||||
} catch { if (shareStatus) shareStatus.textContent = "Ссылка плана не распознана"; }
|
} catch { if (shareStatus) shareStatus.textContent = "Ссылка плана не распознана"; }
|
||||||
}
|
}
|
||||||
@@ -63,9 +82,9 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
const coordinate = document.createElement("strong"); coordinate.textContent = item.coordinates || "Координаты не указаны";
|
const coordinate = document.createElement("strong"); coordinate.textContent = item.coordinates || "Координаты не указаны";
|
||||||
const meta = document.createElement("p"); meta.textContent = item.baits ? `Наживка: ${item.baits.split("|").join(", ")}` : "Наживка не указана";
|
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 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}` : "Риск не рассчитан", freshnessLabel(item.freshness), item.confidence !== "" ? `Доверие: ${item.confidence}%` : "Доверие не рассчитано"].join(" · ");
|
||||||
const link = document.createElement("a"); link.href = item.key || "/"; link.textContent = "Открыть точку";
|
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 remove = document.createElement("button"); remove.type = "button"; remove.dataset.action = "quiet"; remove.textContent = "Убрать"; remove.addEventListener("click", () => { if (writePlan(readPlan().filter(entry => entry.key !== item.key))) render(); });
|
||||||
const actions = document.createElement("div"); actions.className = "plan-card__actions"; actions.append(link, remove);
|
const actions = document.createElement("div"); actions.className = "plan-card__actions"; actions.append(link, remove);
|
||||||
card.append(heading, coordinate, meta, approach, facts, actions); list.append(card);
|
card.append(heading, coordinate, meta, approach, facts, actions); list.append(card);
|
||||||
});
|
});
|
||||||
@@ -103,6 +122,13 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
});
|
});
|
||||||
copyButton?.addEventListener("click", async () => { try { await copyPlanLink(); } catch { if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку"; } });
|
copyButton?.addEventListener("click", async () => { try { await copyPlanLink(); } catch { if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку"; } });
|
||||||
printButton?.addEventListener("click", () => window.print());
|
printButton?.addEventListener("click", () => window.print());
|
||||||
clearButton?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); if (shareStatus) shareStatus.textContent = "План очищен"; render(); });
|
clearButton?.addEventListener("click", () => { try { localStorage.removeItem(planStorageKey); if (shareStatus) shareStatus.textContent = "План очищен"; render(); } catch { if (shareStatus) shareStatus.textContent = "Не удалось очистить план в браузере"; } });
|
||||||
|
importDialog?.addEventListener("close", () => {
|
||||||
|
const choice = importDialog.returnValue;
|
||||||
|
if (!pendingImport || choice === "cancel") { pendingImport = null; return; }
|
||||||
|
const current = readPlan();
|
||||||
|
const merged = choice === "replace" ? pendingImport : normalisePlan([...current, ...pendingImport]);
|
||||||
|
if (writePlan(merged)) { pendingImport = null; if (shareStatus) shareStatus.textContent = choice === "replace" ? "План заменён" : "Точки добавлены в план"; render(); }
|
||||||
|
});
|
||||||
render();
|
render();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ R-пункты уточняют критерии существующих B/G/U/
|
|||||||
- [x] **R03 · P1 · Идемпотентная отправка улова.** Единый replay-контракт теперь сверяет payload hash и TTL также после `IntegrityError`; старый уникальный ключ получает `409`, а завершённый screenshot больше не ломает повтор исходной отправки. Spot/Bait создаются через savepoint и повторный select после unique-конфликта, поэтому ожидаемые гонки не дают необработанный `500`. Regression покрывает одинаковый повтор, изменённый payload, завершённый upload и просроченный ключ.
|
- [x] **R03 · P1 · Идемпотентная отправка улова.** Единый replay-контракт теперь сверяет payload hash и TTL также после `IntegrityError`; старый уникальный ключ получает `409`, а завершённый screenshot больше не ломает повтор исходной отправки. Spot/Bait создаются через savepoint и повторный select после unique-конфликта, поэтому ожидаемые гонки не дают необработанный `500`. Regression покрывает одинаковый повтор, изменённый payload, завершённый upload и просроченный ключ.
|
||||||
- [x] **R04 · P1 · Восстановление загрузки скриншота.** Retry endpoint теперь сохраняет recovery-состояние даже после timeout: форма повторной загрузки остаётся доступной, а повтор безопасен при уже принятом upload. После успешного восстановления удаляются upload- и idempotency-cookie; backend не создаёт второй объект при повторе. Полный browser/network failure drill остаётся отдельной приёмкой.
|
- [x] **R04 · P1 · Восстановление загрузки скриншота.** Retry endpoint теперь сохраняет recovery-состояние даже после timeout: форма повторной загрузки остаётся доступной, а повтор безопасен при уже принятом upload. После успешного восстановления удаляются upload- и idempotency-cookie; backend не создаёт второй объект при повторе. Полный browser/network failure drill остаётся отдельной приёмкой.
|
||||||
- [x] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Предметы и сборки получили независимые offsets и пагинаторы (`items_offset`/`rigs_offset`), один сбой endpoint больше не скрывает второй, 422 фильтров отделён от 503, пустой поиск отделён от незаполненного каталога. Карточки стали article-блоками с отдельной ссылкой названия и валидным source-link внутри паспорта без вложенных ссылок. `astro check` и production build проходят; browser/keyboard acceptance наполненного каталога остаётся частью G06/G09.
|
- [x] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Предметы и сборки получили независимые offsets и пагинаторы (`items_offset`/`rigs_offset`), один сбой endpoint больше не скрывает второй, 422 фильтров отделён от 503, пустой поиск отделён от незаполненного каталога. Карточки стали article-блоками с отдельной ссылкой названия и валидным source-link внутри паспорта без вложенных ссылок. `astro check` и production build проходят; browser/keyboard acceptance наполненного каталога остаётся частью G06/G09.
|
||||||
- [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены.
|
- [x] **R06 · P1 · Надёжный перенос плана (U05).** Share-ссылка показывает preview и явно предлагает объединить, заменить или отменить импорт; `plan` удаляется из URL после чтения, поэтому reload не возвращает удалённые точки. Запись в storage защищена обработкой ошибок, freshness пересчитывается по timestamp, а описание точно говорит о локальном хранении и snapshot в ссылке.
|
||||||
- [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия.
|
- [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия.
|
||||||
- [x] **R08 · P1 · Достоверная аналитика снастей (G07).** Публичные пороги закреплены минимум на 3 наблюдениях и 2 игроках независимо от URL-параметров; окно и freshness используют время улова с fallback на время импорта, ответ ограничен `limit`, а canonical ID назначается только при полном однозначном покрытии компонентных наблюдений. `1/1` нельзя превратить в рекомендацию, unresolved не получает ложную привязку.
|
- [x] **R08 · P1 · Достоверная аналитика снастей (G07).** Публичные пороги закреплены минимум на 3 наблюдениях и 2 игроках независимо от URL-параметров; окно и freshness используют время улова с fallback на время импорта, ответ ограничен `limit`, а canonical ID назначается только при полном однозначном покрытии компонентных наблюдений. `1/1` нельзя превратить в рекомендацию, unresolved не получает ложную привязку.
|
||||||
- [x] **R09 · P1 · Полное удаление личных данных.** Retention и admin-delete загружают дочерние компоненты, очищают их `source_url/raw_payload` вместе с родительским report и сбрасывают upload-token; retention теперь обнаруживает старые reports даже при уже очищенных родительских полях, если provenance осталась в component. Повторная очистка остаётся безопасной; S3/БД failure drill — отдельный эксплуатационный gate.
|
- [x] **R09 · P1 · Полное удаление личных данных.** Retention и admin-delete загружают дочерние компоненты, очищают их `source_url/raw_payload` вместе с родительским report и сбрасывают upload-token; retention теперь обнаруживает старые reports даже при уже очищенных родительских полях, если provenance осталась в component. Повторная очистка остаётся безопасной; S3/БД failure drill — отдельный эксплуатационный gate.
|
||||||
|
|||||||
Reference in New Issue
Block a user