How to use java.time.ZonedDateTime / LocalDateTime in p:calendar

Your concrete problem is that you migrated from Joda’s zoneless date time instance DateTime to Java8’s zoned date time instance ZonedDateTime instead of Java8’s zoneless date time instance LocalDateTime.

Using ZonedDateTime (or OffsetDateTime) instead of LocalDateTime requires at least 2 additional changes:

  1. Do not force a time zone (offset) during date time conversion. Instead, the time zone of the input string, if any, will be used during parsing, and the time zone stored in ZonedDateTime instance must be used during formatting.

    The DateTimeFormatter#withZone() will only give confusing results with ZonedDateTime as it will act as fallback during parsing (it’s only used when time zone is absent in input string or format pattern), and it will act as override during formatting (the time zone stored in ZonedDateTime is entirely ignored). This is the root cause of your observable problem. Just omitting withZone() while creating the formatter should fix it.

    Do note that when you have specified a converter, and don’t have timeOnly="true", then you don’t need to specify <p:calendar timeZone>. Even when you do, you’d rather like to use TimeZone.getTimeZone(zonedDateTime.getZone()) instead of hardcoding it.

  2. You need to carry the time zone (offset) along over all layers, including the database. If your database, however, has a “date time without time zone” column type, then the time zone information gets lost during persist and you will run into trouble when serving back from database.

    It’s unclear which DB you’re using, but keep in mind that some DBs doesn’t support a TIMESTAMP WITH TIME ZONE column type as known from Oracle and PostgreSQL DBs. For example, MySQL does not support it. You’d need a second column.

If those changes are not acceptable, then you need to step back to LocalDateTime and rely on fixed/predefined time zone throughout all layers, including the database. Usually UTC is used for this.


Dealing with ZonedDateTime in JSF and JPA

When using ZonedDateTime with an appropriate TIMESTAMP WITH TIME ZONE DB column type, use the below JSF converter to convert between String in the UI and ZonedDateTime in the model. This converter will lookup the pattern and locale attributes from the parent component. If the parent component doesn’t natively support a pattern or locale attribute, simply add them as <f:attribute name="..." value="...">. If the locale attribute is absent, the (default) <f:view locale> will be used instead. There is no timeZone attribute for the reason as explained in #1 here above.

@FacesConverter(forClass=ZonedDateTime.class)
public class ZonedDateTimeConverter implements Converter {

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object modelValue) {
        if (modelValue == null) {
            return "";
        }

        if (modelValue instanceof ZonedDateTime) {
            return getFormatter(context, component).format((ZonedDateTime) modelValue);
        } else {
            throw new ConverterException(new FacesMessage(modelValue + " is not a valid ZonedDateTime"));
        }
    }

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) {
        if (submittedValue == null || submittedValue.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(submittedValue, getFormatter(context, component));
        } catch (DateTimeParseException e) {
            throw new ConverterException(new FacesMessage(submittedValue + " is not a valid zoned date time"), e);
        }
    }

    private DateTimeFormatter getFormatter(FacesContext context, UIComponent component) {
        return DateTimeFormatter.ofPattern(getPattern(component), getLocale(context, component));
    }

    private String getPattern(UIComponent component) {
        String pattern = (String) component.getAttributes().get("pattern");

        if (pattern == null) {
            throw new IllegalArgumentException("pattern attribute is required");
        }

        return pattern;
    }

    private Locale getLocale(FacesContext context, UIComponent component) {
        Object locale = component.getAttributes().get("locale");
        return (locale instanceof Locale) ? (Locale) locale
            : (locale instanceof String) ? new Locale((String) locale)
            : context.getViewRoot().getLocale();
    }

}

And use the below JPA converter to convert between ZonedDateTime in the model and java.util.Calendar in JDBC (the decent JDBC driver will require/use it for TIMESTAMP WITH TIME ZONE typed column):

@Converter(autoApply=true)
public class ZonedDateTimeAttributeConverter implements AttributeConverter<ZonedDateTime, Calendar> {

    @Override
    public Calendar convertToDatabaseColumn(ZonedDateTime entityAttribute) {
        if (entityAttribute == null) {
            return null;
        }

        Calendar calendar = Calendar.getInstance();
        calendar.setTimeInMillis(entityAttribute.toInstant().toEpochMilli());
        calendar.setTimeZone(TimeZone.getTimeZone(entityAttribute.getZone()));
        return calendar;
    }

    @Override
    public ZonedDateTime convertToEntityAttribute(Calendar databaseColumn) {
        if (databaseColumn == null) {
            return null;
        }

        return ZonedDateTime.ofInstant(databaseColumn.toInstant(), databaseColumn.getTimeZone().toZoneId());
    }

}

Dealing with LocalDateTime in JSF and JPA

When using UTC based LocalDateTime with an appropriate UTC based TIMESTAMP (without time zone!) DB column type, use the below JSF converter to convert between String in the UI and LocalDateTime in the model. This converter will lookup the pattern, timeZone and locale attributes from the parent component. If the parent component doesn’t natively support a pattern, timeZone and/or locale attribute, simply add them as <f:attribute name="..." value="...">. The timeZone attribute must represent the fallback time zone of the input string (when the pattern doesn’t contain a time zone), and the time zone of the output string.

@FacesConverter(forClass=LocalDateTime.class)
public class LocalDateTimeConverter implements Converter {

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object modelValue) {
        if (modelValue == null) {
            return "";
        }

        if (modelValue instanceof LocalDateTime) {
            return getFormatter(context, component).format(ZonedDateTime.of((LocalDateTime) modelValue, ZoneOffset.UTC));
        } else {
            throw new ConverterException(new FacesMessage(modelValue + " is not a valid LocalDateTime"));
        }
    }

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) {
        if (submittedValue == null || submittedValue.isEmpty()) {
            return null;
        }

        try {
            return ZonedDateTime.parse(submittedValue, getFormatter(context, component)).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
        } catch (DateTimeParseException e) {
            throw new ConverterException(new FacesMessage(submittedValue + " is not a valid local date time"), e);
        }
    }

    private DateTimeFormatter getFormatter(FacesContext context, UIComponent component) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(getPattern(component), getLocale(context, component));
        ZoneId zone = getZoneId(component);
        return (zone != null) ? formatter.withZone(zone) : formatter;
    }

    private String getPattern(UIComponent component) {
        String pattern = (String) component.getAttributes().get("pattern");

        if (pattern == null) {
            throw new IllegalArgumentException("pattern attribute is required");
        }

        return pattern;
    }

    private Locale getLocale(FacesContext context, UIComponent component) {
        Object locale = component.getAttributes().get("locale");
        return (locale instanceof Locale) ? (Locale) locale
            : (locale instanceof String) ? new Locale((String) locale)
            : context.getViewRoot().getLocale();
    }

    private ZoneId getZoneId(UIComponent component) {
        Object timeZone = component.getAttributes().get("timeZone");
        return (timeZone instanceof TimeZone) ? ((TimeZone) timeZone).toZoneId()
            : (timeZone instanceof String) ? ZoneId.of((String) timeZone)
            : null;
    }

}

And use the below JPA converter to convert between LocalDateTime in the model and java.sql.Timestamp in JDBC (the decent JDBC driver will require/use it for TIMESTAMP typed column):

@Converter(autoApply=true)
public class LocalDateTimeAttributeConverter implements AttributeConverter<LocalDateTime, Timestamp> {

    @Override
    public Timestamp convertToDatabaseColumn(LocalDateTime entityAttribute) {
        if (entityAttribute == null) {
            return null;
        }

        return Timestamp.valueOf(entityAttribute);
    }

    @Override
    public LocalDateTime convertToEntityAttribute(Timestamp databaseColumn) {
        if (databaseColumn == null) {
            return null;
        }

        return databaseColumn.toLocalDateTime();
    }

}

Applying LocalDateTimeConverter to your specific case with <p:calendar>

You need to change the below:

  1. As the <p:calendar> doesn’t lookup converters by forClass, you’d either need to re-register it with <converter><converter-id>localDateTimeConverter in faces-config.xml, or to alter the annotation as below

     @FacesConverter("localDateTimeConverter")
    
  2. As the <p:calendar> without timeOnly="true" ignores the timeZone, and offers in the popup an option to edit it, you need to remove the timeZone attribute to avoid that the converter gets confused (this attribute is only required when the time zone is absent in the pattern).

  3. You need to specify the desired display timeZone attribute during output (this attribute is not required when using ZonedDateTimeConverter as it’s already stored in ZonedDateTime).

Here’s the full working snippet:

<p:calendar id="dateTime"
            pattern="dd-MMM-yyyy hh:mm:ss a Z"
            value="#{bean.dateTime}"
            showOn="button"
            required="true"
            showButtonPanel="true"
            navigator="true">
    <f:converter converterId="localDateTimeConverter" />
</p:calendar>

<p:message for="dateTime" autoUpdate="true" />

<p:commandButton value="Submit" update="display" action="#{bean.action}" /><br/><br/>

<h:outputText id="display" value="#{bean.dateTime}">
    <f:converter converterId="localDateTimeConverter" />
    <f:attribute name="pattern" value="dd-MMM-yyyy hh:mm:ss a Z" />
    <f:attribute name="timeZone" value="Asia/Kolkata" />
</h:outputText>

In case you intend to create your own <my:convertLocalDateTime> with attributes, you’d need to add them as bean-like properties with getters/setters to the converter class and register it in *.taglib.xml as demonstrated in this answer: Creating custom tag for Converter with attributes

<h:outputText id="display" value="#{bean.dateTime}">
    <my:convertLocalDateTime pattern="dd-MMM-yyyy hh:mm:ss a Z" 
                             timeZone="Asia/Kolkata" />
</h:outputText>

Leave a Comment