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
+2
View File
@@ -770,4 +770,6 @@ export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
export type ScheduledRecordsSummary = {
count: number
nearestValidFrom: string | null
/** Top-50 sorted asc upcoming validFrom ISO timestamps — для TimeTravel marks. */
upcomingValidFroms: string[]
}
@@ -295,15 +295,22 @@ function DictionaryDetail() {
// Уникальные validFrom timestamps для timeline marks. Используем raw
// recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
// фильтров.
// фильтров. PLUS scheduled upcoming validFroms — user feedback: «на таймтревел
// отметки нужны где записи есть или меняются, а не просто +30 и ищи потом».
// После !220 расширили range до +30 дней, теперь добавляем future record
// validFroms как marks чтобы slider показывал «вот тут запись появится».
const recordTimestamps = useMemo(() => {
const set = new Set<number>()
for (const r of recordsResult.data ?? []) {
const ts = new Date(r.validFrom).getTime()
if (!Number.isNaN(ts)) set.add(ts)
}
for (const iso of scheduledSummary.data?.upcomingValidFroms ?? []) {
const ts = new Date(iso).getTime()
if (!Number.isNaN(ts)) set.add(ts)
}
return Array.from(set).sort((a, b) => a - b)
}, [recordsResult.data])
}, [recordsResult.data, scheduledSummary.data?.upcomingValidFroms])
// Column filters — per-column value match (e.g. status='operational',
// operator='Роскосмос'). Auto-derived из schema enum/x-references properties.
@@ -1011,6 +1018,31 @@ function DictionaryDetail() {
/>
)}
{recordsResult.data && recordsResult.data.length > 0
&& scheduledSummary.data && scheduledSummary.data.count > 0
&& scheduledSummary.data.nearestValidFrom && (
// Info banner для non-empty listing когда есть scheduled records.
// User feedback: «нету отметки что есть запись в будущем» — table
// shows только currently active, юзер не видит upcoming. Banner
// duplicates EmptyState hint в non-empty case + CTA jump-to-date.
<div className="flex items-center gap-3 px-3 py-2 mb-2 rounded-sm border border-warn bg-warn-bg text-body">
<ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
<span className="text-cell text-ink">
{t('dict.scheduled.hint', {
count: scheduledSummary.data.count,
date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
})}
</span>
<button
type="button"
onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
className="text-cell text-accent hover:underline ml-auto"
>
{t('dict.scheduled.cta')}
</button>
</div>
)}
{recordsResult.data && recordsResult.data.length > 0 && (
<>
{/* Records toolbar — attached к table panel (tinted top section,
@@ -1332,11 +1364,14 @@ function DictionaryDetail() {
businessKey: rawRecordQuery.data.businessKey,
data: rawRecordQuery.data.data,
// Edit в bitemporal model = «создаём новую версию с этого момента,
// старая закрывается». validFrom оставляем пустым — handleSubmit
// подставит nowIsoLocal() at submit-time (review #5: иначе stale
// если user долго держит drawer открытым).
// старая закрывается». Prefill nowIsoLocal() чтобы юзер видел
// конкретную дату («сейчас» как точка отсчёта), а не пустое поле.
// User feedback: "Действует с показывает ничего, а даты заданы" —
// empty field выглядит как баг. handleSubmit всё равно подставит
// свежий nowIsoLocal() если поле не тронуто (review #5 защита от
// stale значения если drawer долго открыт).
// validTo пустой = бессрочно (или до следующей правки).
validFrom: '',
validFrom: nowIsoLocal(),
validTo: '',
}}
onSubmit={handleSubmit}
@@ -203,4 +203,29 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("dictionaryId") UUID dictionaryId,
@Param("allowedScopes") Collection<DataScope> allowedScopes,
@Param("now") OffsetDateTime now);
/**
* Список validFrom timestamps для всех scheduled records (validFrom > now,
* validTo > now) sorted asc, capped в service слое.
*
* <p>Используется TimeTravel slider marks: показывает «вот тут запись появится»
* pinpoints вместо blind drag вправо после !220 +30day window fix.
* User feedback: «отметки нужны где записи есть или меняются».
*
* <p>{@link org.springframework.data.domain.Pageable Pageable} caller'а
* ограничивает (top 50 typical) DB sort + limit на index дешевле полной выборки.
*/
@Query("""
SELECT r.validFrom FROM DictionaryRecord r
WHERE r.dictionaryId = :dictionaryId
AND r.dataScope IN :allowedScopes
AND r.validFrom > :now
AND r.validTo > :now
ORDER BY r.validFrom ASC
""")
List<OffsetDateTime> findUpcomingValidFroms(
@Param("dictionaryId") UUID dictionaryId,
@Param("allowedScopes") Collection<DataScope> allowedScopes,
@Param("now") OffsetDateTime now,
org.springframework.data.domain.Pageable pageable);
}
@@ -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 {