gendesign/frontend/src/components/site-finder/NspdFreshnessBadge.tsx
lekss361 990c109756 fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C)
Backend (quarter_dump_lookup.py):
- _acquire_harvest_lock: Redis SETNX TTL=120s на quarter, защищает от burst
  N concurrent analyze, ставящих N одинаковых harvest task в очередь
- _trigger_harvest: использует lock перед apply_async, возвращает False если
  lock уже взят (другой запрос триггернул раньше)
- make_empty_result/EMPTY_DUMP_RESULT: новое поле harvest_eta_seconds в
  nspd_dump dict, типичный harvest_quarter = 60с
- /analyze: пробрасывает поле через nspd_dump dict (нет typed schema —
  response_model=None для /analyze endpoint, dict уходит как есть)

Frontend (NspdFreshnessBadge, NspdZoningBlock):
- Countdown «НСПД: загрузка ~Nс» вместо бесконечного спиннера
- После остановки countdown (remaining=0) NspdZoningBlock показывает
  «загрузка дольше обычного» + ссылку на ПКК вместо infinite skeleton

Tests: 5 новых unit + 2 для empty_result schema (всего +7, pass)

Closes #234 (UX-side; data-side resolves когда Sub-PR B + D merged).
2026-05-17 09:13:06 +03:00

137 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import type { NspdDumpMeta } from "@/types/nspd";
interface Props {
dump: NspdDumpMeta | null | undefined;
}
export function NspdFreshnessBadge({ dump }: Props) {
// Issue #234: countdown пока harvest идёт. Сбрасывается на ETA при каждом
// новом triggered=true response (или ETA значении).
const eta = dump?.harvest_eta_seconds ?? null;
const [remaining, setRemaining] = useState<number | null>(eta);
useEffect(() => {
setRemaining(eta);
}, [eta]);
useEffect(() => {
if (remaining === null || remaining <= 0) return;
const id = setInterval(() => {
setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1));
}, 1000);
return () => clearInterval(id);
}, [remaining]);
if (!dump) {
return (
<span
style={{
display: "inline-block",
background: "#f3f4f6",
color: "#6b7280",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title="Данные НСПД недоступны"
>
НСПД: нет данных
</span>
);
}
if (dump.harvest_triggered && !dump.available) {
const countdownLabel =
remaining !== null && remaining > 0
? `НСПД: загрузка ~${remaining}с`
: "НСПД: загрузка…";
return (
<span
style={{
display: "inline-block",
background: "#dbeafe",
color: "#1e40af",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title={
eta !== null
? `Загрузка данных НСПД, осталось ~${remaining ?? 0} сек`
: "Загрузка данных НСПД запущена"
}
>
{countdownLabel}
</span>
);
}
if (!dump.available) {
return (
<span
style={{
display: "inline-block",
background: "#f3f4f6",
color: "#6b7280",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title="Дамп НСПД для квартала не загружен"
>
НСПД: нет дампа
</span>
);
}
const dateStr = dump.fetched_at_utc
? new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")
: null;
if (dump.stale) {
return (
<span
style={{
display: "inline-block",
background: "#fef3c7",
color: "#92400e",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title={
dateStr
? `Данные от ${dateStr} (устаревшие)`
: "Устаревшие данные НСПД"
}
>
НСПД: устарело{dateStr ? ` (${dateStr})` : ""}
</span>
);
}
return (
<span
style={{
display: "inline-block",
background: "#dcfce7",
color: "#15803d",
borderRadius: 6,
padding: "1px 8px",
fontSize: 11,
fontWeight: 500,
}}
title={dateStr ? `Данные НСПД от ${dateStr}` : "Данные НСПД актуальны"}
>
НСПД: актуально{dateStr ? ` (${dateStr})` : ""}
</span>
);
}