feat(records): scheduled records empty-state hint

Fixes UX bug: пользователь создаёт запись с validFrom на будущую дату
(e.g. 18.05), открывает catalog 15.05 — пустая таблица, никаких хинтов
о том что запись существует.

Backend (read-api):
- New endpoint GET /api/v1/{dict}/records/scheduled-summary
- Returns {count, nearestValidFrom} для записей с validFrom > now AND
  validTo > now в текущем scope view
- Single-aggregate JPA query (COUNT + MIN), respect scope filter same way
  as listActive — INTERNAL scheduled не учитывается для PUBLIC consumer'а

Frontend:
- New useScheduledSummary hook + ScheduledRecordsSummary type
- Empty-state в dictionaries.$name now shows: "Запланировано N, ближайшая
  на DD.MM [Перейти к этой дате]" с CTA который set'ает time-travel на
  nearestValidFrom
- i18n keys для ru/en plural forms

Связан с MR !219 (TimeTravelPicker 30-day future window) — теперь slider
позволяет дойти до даты записи, и empty-state hint позволяет узнать что
запись существует.
This commit is contained in:
Zimin A.N.
2026-05-15 20:17:07 +03:00
parent 4c3327b05c
commit 234184645f
8 changed files with 205 additions and 1 deletions
@@ -0,0 +1,22 @@
package cloud.nstart.terravault.ordinis.readapi.dto;
import java.time.OffsetDateTime;
/**
* Empty-state hint payload: сколько записей запланировано в будущем и
* на какую ближайшую дату. Используется admin-UI чтобы показать
* «У словаря есть N запланированных, ближайшая на DD.MM» вместе с
* CTA «Перейти к дате» (set time-travel).
*
* <p>{@code count == 0} → {@code nearestValidFrom == null}; frontend
* не показывает hint в этом случае.
*
* <p>Scope filtering: same view permissions that {@code listActive}
* использует — INTERNAL scheduled не учитывается для PUBLIC consumer'а.
*/
public record ScheduledRecordsSummary(long count, OffsetDateTime nearestValidFrom) {
public static ScheduledRecordsSummary empty() {
return new ScheduledRecordsSummary(0L, null);
}
}
@@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRep
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary;
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
@@ -172,6 +173,45 @@ public class RecordReadService {
.toList();
}
/**
* Scheduled records summary: подсчитать записи у которых validFrom в будущем
* (но validTo тоже в будущем — т.е. не сразу закрытые). Используется
* admin-UI empty-state hint: «У dict есть N запланированных, ближайшая на DD.MM».
*
* <p>Scope rules:
* <ul>
* <li>Если scope словаря недоступен — 404 (NoSuchElementException), как в listActive</li>
* <li>Иначе counts только записи в {@code allowedScopes} (INTERNAL не виден PUBLIC consumer'у)</li>
* </ul>
*
* <p>Empty result (count=0) — нормальное состояние, возвращается явный
* {@link ScheduledRecordsSummary#empty()} (не null) для уменьшения NPE-риска
* на frontend.
*/
@Transactional(readOnly = true)
public ScheduledRecordsSummary scheduledSummary(
String dictionaryName, List<DataScope> allowedScopes) {
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
if (!allowedScopes.contains(def.getScope())) {
throw new ScopeAccessDeniedException(
"Scope " + def.getScope() + " not in allowed scopes " + allowedScopes);
}
OffsetDateTime now = OffsetDateTime.now();
List<Object[]> rows = recordRepository.findScheduledSummary(
def.getId(), allowedScopes, now);
if (rows.isEmpty()) return ScheduledRecordsSummary.empty();
Object[] row = rows.get(0);
long count = row[0] == null ? 0L : ((Number) row[0]).longValue();
OffsetDateTime nearest = (OffsetDateTime) row[1];
if (count == 0L || nearest == null) return ScheduledRecordsSummary.empty();
return new ScheduledRecordsSummary(count, nearest);
}
public static class ScopeAccessDeniedException extends RuntimeException {
public ScopeAccessDeniedException(String message) { super(message); }
}
@@ -3,6 +3,7 @@ package cloud.nstart.terravault.ordinis.readapi.web;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary;
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon;
@@ -105,6 +106,27 @@ public class DictionaryReadController {
return new ResponseEntity<>(items, headers, 200);
}
/**
* Scheduled records summary: empty-state hint для admin-UI. Возвращает
* {@code {count, nearestValidFrom}} для записей с {@code validFrom > now AND validTo > now}
* в текущем scope view.
*
* <p>Use case: пользователь создаёт запись с {@code validFrom} на будущее (e.g. 18.05),
* затем заходит в catalog 15.05 — табличка пустая. Frontend читает этот endpoint
* и показывает «У словаря есть N запланированных, ближайшая на DD.MM. [Перейти к дате]».
*
* <p>{@code count=0} → frontend ничего не показывает. Дешёвый single query, OK к poll'ить
* с те же refetchInterval'ом что и records list.
*/
@GetMapping("/scheduled-summary")
public ScheduledRecordsSummary scheduledSummary(
@PathVariable String dictionaryName,
@RequestParam(value = "as_scope", required = false) String asScope) {
List<DataScope> scopes = scopeContext.resolveForRead(asScope).stream().toList();
return service.scheduledSummary(dictionaryName, scopes);
}
/** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */
public static class BadBboxException extends RuntimeException {
public BadBboxException(String message) { super(message); }