feat: degrade home sections independently

This commit is contained in:
ik
2026-09-13 09:28:12 +07:00
parent 0abe1a2bb4
commit 016d459861
3 changed files with 33 additions and 15 deletions
+30 -14
View File
@@ -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<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>(`/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<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"),
api<PublicObservation[]>(`/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<PaginatedActivity>(`/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,
};
---
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex} errorPage={filterError || unavailable}>
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex} errorPage={filterError || allUnavailable}>
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
{catalogUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Часть справочников недоступна" description="Фильтры показаны без непроверенных значений; остальные разделы загружаются независимо." /></div>}
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
@@ -70,10 +85,11 @@ const datasetJsonLd = {
<button data-action="primary">⌕ Найти клёв</button>
</form></section>
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section>
{signals.length > 0 && <SignalFeed signals={signals}/>}
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
<script>