diff --git a/apps/web/src/pages/fish/index.astro b/apps/web/src/pages/fish/index.astro index 26fa668..d4ff1d7 100644 --- a/apps/web/src/pages/fish/index.astro +++ b/apps/web/src/pages/fish/index.astro @@ -23,7 +23,7 @@ if (unavailable) { Astro.response.headers.set("Cache-Control", "no-store"); } --- - + {unavailable ? : } {!unavailable && } diff --git a/apps/web/src/pages/media.astro b/apps/web/src/pages/media.astro index a0298d8..f8651a8 100644 --- a/apps/web/src/pages/media.astro +++ b/apps/web/src/pages/media.astro @@ -16,7 +16,7 @@ if (unavailable) { } const labels: Record = {fish:"Рыбы",waterbody:"Водоёмы",tackle:"Снасти и приманки",reference:"Справочные материалы"}; --- - +
diff --git a/apps/web/src/pages/records.astro b/apps/web/src/pages/records.astro index cf485c5..c16efe7 100644 --- a/apps/web/src/pages/records.astro +++ b/apps/web/src/pages/records.astro @@ -25,7 +25,13 @@ try { items = (await api(`/api/v1/records?${query}`)).items; } [runs, fishes, waterbodies] = await Promise.all([api("/api/v1/imports?limit=1"), api("/api/v1/fishes"), api("/api/v1/waterbodies")]); -} catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); } +} catch { + unavailable = true; + showNoIndex = true; + Astro.response.status = 503; + Astro.response.headers.set("Retry-After", "60"); + Astro.response.headers.set("Cache-Control", "no-store"); +} const last = runs[0]; --- diff --git a/apps/web/src/pages/report.astro b/apps/web/src/pages/report.astro index 19ab4fd..460d416 100644 --- a/apps/web/src/pages/report.astro +++ b/apps/web/src/pages/report.astro @@ -2,11 +2,17 @@ import Layout from "../layouts/Layout.astro"; import { api, type DictionaryItem } from "../lib/api"; let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false; -try { [fishes, waterbodies] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies")]); } catch { unavailable = true; } +try { [fishes, waterbodies] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies")]); } +catch { + unavailable = true; + Astro.response.status = 503; + Astro.response.headers.set("Retry-After", "60"); + Astro.response.headers.set("Cache-Control", "no-store"); +} const state = Astro.url.searchParams.get("state"); const reportId = Astro.url.searchParams.get("report_id"); --- - +
+1 Помочь сообществу

Добавить
свой улов

Около минуты — и рабочая точка появится в общей статистике после проверки модератором.

{state === "sent" &&
Улов отправлен на модерацию. Спасибо!
} @@ -17,7 +23,7 @@ const reportId = Astro.url.searchParams.get("report_id"); {state === "server_error" &&
Сервер временно недоступен. Попробуйте позже.
} {state === "screenshot_error" &&
Заявка сохранена без скриншота. Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.
} {state === "screenshot_error" && reportId &&
} - {unavailable ?

Форма временно недоступна

:
+ {unavailable ? :
Главное обязательно

Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.

Дополнительные сведения необязательно

Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.

} diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index 637bee9..6f3f55b 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -22,7 +22,10 @@ try { } catch (error) { unavailable = true; Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503; - if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60"); + if (Astro.response.status === 503) { + Astro.response.headers.set("Retry-After", "60"); + Astro.response.headers.set("Cache-Control", "no-store"); + } } const level = activity ? activityLevel(activity.activity_score) : null; const methodLabels: Record = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" }; @@ -35,9 +38,9 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr { "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` }, ] } : null; --- - + - {unavailable || !spot ?

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

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

: <> + {unavailable || !spot ? : <>
{spot.waterbody}

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

{spot.description}

Точность координат: {coordinatePrecisionLabel(spot.coordinate_precision)}

{spot.catches_24h}за 24 часа
{spot.catches_3d}за 3 дня
{spot.catches_7d}за 7 дней
diff --git a/apps/web/src/pages/status.astro b/apps/web/src/pages/status.astro index cb15270..8b9cff1 100644 --- a/apps/web/src/pages/status.astro +++ b/apps/web/src/pages/status.astro @@ -6,9 +6,15 @@ import PageHero from "../components/PageHero.astro"; import StatePanel from "../components/StatePanel.astro"; import { ago, api, plural, sourceStatusLabel, type SourceStatus } from "../lib/api"; let sources: SourceStatus[] = [], unavailable = false; -try { sources = await api("/api/v1/source-status"); } catch { unavailable = true; } +try { sources = await api("/api/v1/source-status"); } +catch { + unavailable = true; + Astro.response.status = 503; + Astro.response.headers.set("Retry-After", "60"); + Astro.response.headers.set("Cache-Control", "no-store"); +} --- - + {unavailable ? :
{sources.map(source =>
{sourceStatusLabel(source.status)}

{source.name}

{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}

{source.status === "temporarily_limited" ? "Источник ограничен; опубликованные данные сохранены." : source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}

)}
} diff --git a/apps/web/src/pages/tackle/analytics.astro b/apps/web/src/pages/tackle/analytics.astro index 702c45f..c680258 100644 --- a/apps/web/src/pages/tackle/analytics.astro +++ b/apps/web/src/pages/tackle/analytics.astro @@ -25,7 +25,7 @@ if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retr const methodLabels: Record = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" }; const roleLabels: Record = { lure: "Приманка", bait: "Наживка", rig: "Монтаж", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" }; --- - +
diff --git a/apps/web/src/pages/tackle/index.astro b/apps/web/src/pages/tackle/index.astro index a4beb7d..178579b 100644 --- a/apps/web/src/pages/tackle/index.astro +++ b/apps/web/src/pages/tackle/index.astro @@ -27,7 +27,7 @@ if (category) filterParams.set("category", category); if (brand) filterParams.set("brand", brand); if (family) filterParams.set("family", family); --- - + diff --git a/apps/web/src/pages/waterbodies/index.astro b/apps/web/src/pages/waterbodies/index.astro index 72419c3..a832023 100644 --- a/apps/web/src/pages/waterbodies/index.astro +++ b/apps/web/src/pages/waterbodies/index.astro @@ -21,7 +21,7 @@ if (unavailable) { Astro.response.headers.set("Cache-Control", "no-store"); } --- - + {unavailable ? : } {!unavailable && } diff --git a/apps/web/tests/degradation.spec.ts b/apps/web/tests/degradation.spec.ts index 731110b..3216305 100644 --- a/apps/web/tests/degradation.spec.ts +++ b/apps/web/tests/degradation.spec.ts @@ -11,6 +11,10 @@ for (const path of [ "/tackle", "/tackle/analytics", "/tackle/items/00000000-0000-0000-0000-000000000000", + "/records", + "/spots/unknown-0x0", + "/report", + "/status", ]) { test(`${path} exposes a cache-safe unavailable state`, async ({ page }) => { test.skip(!unavailableRun, "Run with EXPECT_UNAVAILABLE=1 and an API-unavailable web process."); @@ -18,7 +22,8 @@ for (const path of [ expect(response?.status(), `${path} status`).toBe(503); expect(response?.headers()["retry-after"], `${path} retry header`).toBe("60"); expect(response?.headers()["cache-control"], `${path} cache header`).toBe("no-store"); - await expect(page.getByRole("alert")).toContainText(/временно недоступ|недоступен/i); + await expect(page.getByRole("alert")).toContainText(/временно недоступ|недоступ/i); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex, nofollow"); await expect(page.locator("body")).not.toContainText(/Traceback|localhost:8000|API \d+/i); }); } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 025b268..a809b30 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -56,7 +56,7 @@ - [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. RF4MAP/RF4-STAT directory parsers теперь преобразуются в crosswalk identities без переноса метрик или автоматической привязки. Осталось подать полный набор реальных RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта. - [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. Все public-компоненты используют единый `coordinatePrecisionLabel` с безопасным fallback и unit-регрессией четырёх состояний. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow; browser regression для отсутствующей точки проверяет недоступное состояние без ложных координат, а API regression параметризован по всем четырём precision-состояниям. Осталась полная browser QA матрица для всех precision/error-состояний. - [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Public media API теперь передаёт reviewed `media_role` и дополнительно отбрасывает unknown/cross-entity роли на границе публикации, waterbody detail принимает только `waterbody_cover`/`waterbody_map`/`waterbody_depth_map`, а UI подписывает карту/заставку/скриншот текстом. Осталось завершить browser QA для реальных role-вариантов и не показывать abstract fingerprint как внешнее изображение. -- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью, not-found waterbody и not-found waterbody/fish pair, а spot regression — unavailable state без ложной координаты; каталоги, media, analytics и tackle detail теперь имеют opt-in regression (`EXPECT_UNAVAILABLE=1`) для `503` + `Retry-After` + `no-store` без утечки внутренних ошибок. Полный web E2E и visual-matrix остаются обязательным финальным прогоном; внешние detail-данные закрываются отдельно. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff. +- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью, not-found waterbody и not-found waterbody/fish pair, а spot regression — unavailable state без ложной координаты; публичные каталоги, media, records, spot/report/status, analytics и tackle detail имеют opt-in regression (`EXPECT_UNAVAILABLE=1`) для `503` + `Retry-After` + `no-store`, `noindex` и отсутствия утечки внутренних ошибок. Полный web E2E и visual-matrix остаются обязательным финальным прогоном; внешние detail-данные закрываются отдельно. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff. ### Каталог снастей, наживок и прочей оснастки