Отображение времени с учетом часового пояса пользователя

Здравствуйте, подскажите, пожалуйста, как отображать даты и время в пользовательском интерфейсе с учетом часового пояса пользователя при условии, что в базе данных это все хранится в UTC?

Добрый день,

Можете более детально описать:

  • какая версия Jmix
  • Какой исходный тип даты вы хотите фарматировать
  • какие то еще дополнительные детали проблемы

Версия Jmix 2.0.2
Хочу отформатировать LocalDateTime для того, чтобы в таблицах (и не только в них) отображалось время с учетом временной зоны, которую он непосредственно указал в настройках

Добрый день,
Извиняюсь за задержу.

Проблема: Связь с временными зонами

  • LocalDateTime не имеет понятия о временных зонах. Это просто дата и время в локальном контексте.
  • ZonedDateTime связывает LocalDateTime с временной зоной, учитывая правила перехода на летнее/зимнее время.
  • OffsetDateTime связывает LocalDateTime с фиксированным смещением от UTC, не учитывая правила перехода на летнее/зимнее время.

Решение

В Jmix есть 2 способа получить зону клиента.

Источник тайм зоны для логики отображения.

  1. Spring-ый из SecurityContext.

    currentAuthentication.getTimeZone().toZoneId()
    

    Всегда статическое и получается из сессии.

  2. из пользователя Jmix. Тут есть конфигурируемое поле в Users:

        final User user = (User) currentAuthentication.getUser();
        user.getTimeZoneId();
    

    Получается из настроек и может быть null. А еще - если поменять, то только после пере-логина и форматы поменяются.

Логика форматирования:

Нам надо просто с использованием зоны и знаний LDT конвертировать его в строку.

private String convertUtcToUserTimezone(LocalDateTime utcTime, String userTimezone) {
        if(utcTime == null) {
            return "";
        }
        ZonedDateTime utcZonedDateTime = utcTime.atZone(ZoneId.of("UTC"));
        ZonedDateTime userZonedDateTime = utcZonedDateTime.withZoneSameInstant(ZoneId.of(userTimezone));
        // your pattern here, may be provided from some service or configuration
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z");
        return userZonedDateTime.format(formatter);
    }

UI

  1. Таблицы. Получаем необходимую отформатировать колонку и даем ей Renderer:

    // здесь я специально опустил ZoneProvider тк решите сами как хотите получать зону
    public <T> ItemLabelGenerator<T> newGenerator(Function<T, LocalDateTime> ldtProvider) {
      return entity -> convertUtcToUserTimezone(ldtProvider.apply(entity), userZoneProvider.getCurrentUserZoneIdString());
    }
    
    // добавляем рендерер на необходимую таблицу
    @Subscribe
    public void onBeforeShow(final BeforeShowEvent event) {
        usersDataGrid.getColumnByKey("ldtColumn")
                .setRenderer(new TextRenderer<>(
                        localDateTimeLabelGeneratorProvider.newGenerator(User::getLdt)));
    }
    
  2. филды:

    ldtField.setZoneId(currentAuthentication.getTimeZone().toZoneId());
    

Domain-based solution

создаем вычисляемое поле типа OffsetDateTime.

   @DependsOnProperties("ldt")
    @JmixProperty
    public OffsetDateTime getOffsetLdt() {
        if(this.ldt == null) {
            return null;
        }
        return TimeConverter.fromLocalDateTime(this.ldt);
    }

    @DependsOnProperties("ldt")
    public void setOffsetLdt(OffsetDateTime offsetLdt) {
        LocalDateTime localDateTime = TimeConverter.convertFromOffsetDateTime(offsetLdt);
        // prevent recursive update due to collection value change listeners
        if(!localDateTime.equals(this.ldt)){
            // updating linked fields
            setLdt(localDateTime);
        }
    }

    public LocalDateTime getLdt() {
        return ldt;
    }

    public void setLdt(LocalDateTime ldt) {
        this.ldt = ldt;
    }

Util class:

public final class TimeConverter {

    // copied from io.jmix.core.security.CurrentAuthentication
    public static TimeZone getCurrentTimeZone() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Object details = authentication.getDetails();
        Object principal = authentication.getPrincipal();
        TimeZone timeZone = null;
        if (principal instanceof HasTimeZone) {
            String timeZoneId = ((HasTimeZone)principal).getTimeZoneId();
            if (!Strings.isNullOrEmpty(timeZoneId)) {
                timeZone = TimeZone.getTimeZone(timeZoneId);
            }
        } else if (details instanceof ClientDetails) {
            timeZone = ((ClientDetails)details).getTimeZone();
        }

        return timeZone == null ? TimeZone.getDefault() : timeZone;
    }

    public static LocalDateTime convertFromOffsetDateTime(OffsetDateTime offsetDateTime) {
        OffsetDateTime utcOffsetDateTime = offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC);
        return utcOffsetDateTime.toLocalDateTime();
    }

    public static OffsetDateTime fromLocalDateTime(LocalDateTime localDateTime) {
        ZoneId zoneId = getCurrentTimeZone().toZoneId();
        ZonedDateTime zonedDateTime = localDateTime.atZone(zoneId);
        return zonedDateTime.toOffsetDateTime();
    }
}

Плюсы:

  • такое поле легко использовать в экранах - оно сразу с оффетом на зону UTC
  • не учитывает переходы на зимнее и летнее время(не страшно для Европы и России)

Минусы:

  • Бизнес логика в доменной области - смешивать императивный стиль бизнес сценариев в сервисах и бизнес в домене - не советую
  • сложно для понимания
  • Необходимо понимать работу VaueChange event-ов в Jmix - может привести к багам и StackOverflow
  • я сделал связь только при изменениях offsetLdt - если его меняют, то поменяется и оригинальный ldt. Чтобы в обратную сторону сработало надо добавлять транзиентный флаг и при setLdt ставить его на true, прокидывать setOffsetDateTime() и потом снова на false. В общем - еще одна обработка SOF.

В рамках Jmix, все же советую использовать первый вариант и просто делегировать логику форматирования каким то сервисам. Полагаю, что у вас решение внутри россии, никаких впн нету и нигде сохранять ничего не надо. Так что не так уж сложно просто проставить везде форматтеры.

Общее решение.

На сколько мне известно, заставить LocalDateTime само-конвертироваться в зональное время в Jmix из коробки нету, тк есть OffsetDateTime и ZonedDateTime.

С уважением,
Дмитрий

2 симпатии

UPD: Альтернативно можно создать кастомный тип данных и его маппить и юзать вместо LDT
https://docs.jmix.io/jmix/data-model/data-types.html#custom-type