fix(records): scheduled marks on time-travel + non-empty banner + editor validFrom prefill

Три связанных UX бага после v2.31.0 release:

1. **TimeTravel marks**: после !220 slider extended до now+30d, но отметок в
   future window не было → blind drag. Backend теперь возвращает
   upcomingValidFroms (top 50) в /scheduled-summary; frontend merges как marks.

2. **Non-empty banner**: empty-state hint работает только когда список пуст.
   Если currently active записи есть AND ещё запланированы — пользователь
   не видит. Добавлен info banner над таблицей с тем же текстом + CTA.

3. **Editor "Действует с"**: edit mode оставлял поле пустым (bitemporal
   policy «новая версия с now»). User feedback: «показывает ничего, а даты
   заданы» — выглядит как баг. Prefill nowIsoLocal() — теперь юзер видит
   конкретную дату как точку отсчёта.
This commit is contained in:
Zimin A.N.
2026-05-15 21:28:29 +03:00
parent 1713951f76
commit 9199f380e1
5 changed files with 94 additions and 15 deletions
@@ -1,22 +1,32 @@
package cloud.nstart.terravault.ordinis.readapi.dto;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Empty-state hint payload: сколько записей запланировано в будущем и
* на какую ближайшую дату. Используется admin-UI чтобы показать
* «У словаря есть N запланированных, ближайшая на DD.MM» вместе с
* CTA «Перейти к дате» (set time-travel).
* Empty-state hint payload + future-marks data: сколько записей запланировано,
* на какую ближайшую дату, и список upcoming validFrom timestamps для
* TimeTravel slider marks.
*
* <p>{@code count == 0} → {@code nearestValidFrom == null}; frontend
* не показывает hint в этом случае.
* <ul>
* <li>{@code count} — total scheduled (validFrom > now AND validTo > now)</li>
* <li>{@code nearestValidFrom} — earliest upcoming, для CTA «Перейти к дате»</li>
* <li>{@code upcomingValidFroms} — sorted asc, capped (50 by default).
* Frontend merges as marks на time-travel slider — fixes UX где
* slider extended до +30d, но без отметок где реально что-то происходит</li>
* </ul>
*
* <p>{@code count == 0} → nearest=null, upcoming=empty list.
*
* <p>Scope filtering: same view permissions that {@code listActive}
* использует — INTERNAL scheduled не учитывается для PUBLIC consumer'а.
*/
public record ScheduledRecordsSummary(long count, OffsetDateTime nearestValidFrom) {
public record ScheduledRecordsSummary(
long count,
OffsetDateTime nearestValidFrom,
List<OffsetDateTime> upcomingValidFroms) {
public static ScheduledRecordsSummary empty() {
return new ScheduledRecordsSummary(0L, null);
return new ScheduledRecordsSummary(0L, null, List.of());
}
}
@@ -209,7 +209,14 @@ public class RecordReadService {
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);
// Top-50 upcoming validFrom timestamps для TimeTravel slider marks.
// 50 — баланс между UX (видно все основные точки изменений)
// и payload size (50 ISO timestamps ≈ 1.6KB).
List<OffsetDateTime> upcoming = recordRepository.findUpcomingValidFroms(
def.getId(), allowedScopes, now,
org.springframework.data.domain.PageRequest.of(0, 50));
return new ScheduledRecordsSummary(count, nearest, upcoming);
}
public static class ScopeAccessDeniedException extends RuntimeException {