From 016d4598614d7b142d43588721192273315f6b3f Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 09:28:12 +0700 Subject: [PATCH] feat: degrade home sections independently --- README.md | 2 ++ apps/web/src/pages/index.astro | 44 +++++++++++++++++++++++----------- docs/ROADMAP.md | 2 +- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7525a5b..474b8a8 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Web Docker-образ устанавливает зависимости чере Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. В production Compose включён community scheduler; локально он запускается отдельным профилем. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений; публичный адрес обратной связи ещё не задан. +Главная деградирует по секциям: activity, community signals и справочники загружаются независимо. Частичный отказ сохраняет доступные данные и возвращает HTTP 200 с `X-RF4-Partial` и запретом кэширования; общий 503 возникает только при отказе всех частей. + RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку. diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index aed692a..da8d124 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -5,6 +5,7 @@ import FishingIcon from "../components/FishingIcon.astro"; import SourceBadge from "../components/SourceBadge.astro"; import TackleGlyph from "../components/TackleGlyph.astro"; import SignalFeed from "../components/SignalFeed.astro"; +import StatePanel from "../components/StatePanel.astro"; import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api"; const params = Astro.url.searchParams; @@ -19,27 +20,40 @@ const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.m let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let hasMoreSignals = false, totalItems = 0; const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort); -let unavailable = false, showNoIndex = false; +let activityUnavailable = false, signalsUnavailable = false, fishCatalogUnavailable = false, waterCatalogUnavailable = false, showNoIndex = false; if (filterError) { showNoIndex = true; Astro.response.status = 422; } -try { - let signalRows: PublicObservation[] = []; - const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) }); - if (waterbody) signalParams.set("waterbody", waterbody); - if (fish) signalParams.set("fish", fish); - [fishes, waterbodies, signalRows] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies"), api(`/api/v1/community-observations?${signalParams}`)]); +let signalRows: PublicObservation[] = []; +const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) }); +if (waterbody) signalParams.set("waterbody", waterbody); +if (fish) signalParams.set("fish", fish); +const [fishResult, waterResult, signalResult] = await Promise.allSettled([ + api("/api/v1/fishes"), api("/api/v1/waterbodies"), + api(`/api/v1/community-observations?${signalParams}`), +]); +if (fishResult.status === "fulfilled") fishes = fishResult.value; else fishCatalogUnavailable = true; +if (waterResult.status === "fulfilled") waterbodies = waterResult.value; else waterCatalogUnavailable = true; +if (signalResult.status === "fulfilled") signalRows = signalResult.value; else signalsUnavailable = true; +if (!signalsUnavailable) { hasMoreSignals = signalRows.length > signalLimit; signals = signalRows.slice(0, signalLimit); - if (!filterError) { - const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) }); +} +if (!filterError) { + const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) }); + try { const paginated = await api(`/api/v1/activity?${query}`); items = offset > 0 ? [...items, ...paginated.items] : paginated.items; totalItems = paginated.total; - } -} catch { - unavailable = true; + } catch { activityUnavailable = true; } +} +const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable; +const allUnavailable = activityUnavailable && signalsUnavailable && fishCatalogUnavailable && waterCatalogUnavailable; +if (!filterError && allUnavailable) { showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); +} else if (activityUnavailable || signalsUnavailable || catalogUnavailable) { + Astro.response.headers.set("X-RF4-Partial", [activityUnavailable && "activity", signalsUnavailable && "signals", catalogUnavailable && "catalog"].filter(Boolean).join(",")); + Astro.response.headers.set("Cache-Control", "no-store"); } const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null; const selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы"; @@ -58,8 +72,9 @@ const datasetJsonLd = { creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true, }; --- - +
RF4 Живая карта клёва

Выбирай место,
пока клюёт.

Свежие точки, рабочие приманки и честная оценка данных от игроков.

Туманное озеро на рассвете
Пульс водоёмов{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность
+ {catalogUnavailable &&
}
@@ -70,10 +85,11 @@ const datasetJsonLd = {
{selectedWaterbody}{selectedFish}{periodLabel}{sortLabel}{filtersChanged && Сбросить}
-
За выбранный период

Горячие точки

{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте позже.

: items.length ? <>
{items.map(item => )}
{offset + items.length < totalItems && { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё {offset + items.length} из {totalItems}} :

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
+
За выбранный период

Горячие точки

{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: activityUnavailable ? : items.length ? <>
{items.map(item => )}
{offset + items.length < totalItems && { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё {offset + items.length} из {totalItems}} :

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
{items[0] && leaderLevel && }
{signals.length > 0 && } + {signalsUnavailable &&
} {hasMoreSignals && signalLimit < 48 && Показать ещё {signalLimit} из доступных}
Как читать данные

Не обещаем рыбу.
Показываем факты.

01

Свежесть

Чем старше сообщение, тем меньше оно влияет на активность.

02

Разные игроки

Десять уловов одного человека не равны десяти подтверждениям.

03

Уверенность

Каждая оценка объясняет, сколько данных за ней стоит.