minor
#29306
TL Script: dateFormat() should accept an explicit timezone to format Calendar values losslessly
Problem
Formatting a Calendar value via dateFormat(...).format($cal.toDate()) was not lossless when the TopLogic system/user timezone differs from the JVM default timezone. The label can shift by one day at midnight, and at month/year boundaries by a whole month or year.
The root cause is that dateFormat() rendered values in the JVM default timezone (TimeZone.getDefault()) — unlike the locale, which it already took from ThreadContext.getLocale(). The JVM default timezone should never be used in application code.
Reproduction (Gantt demo, #29108)
Configure the application's system timezone east of the JVM default by more than the day-of-month boundary distance (e.g. system timezone = Asia/Tokyo, JVM default = UTC). The Gantt demo's axis labels then come out one period early:
- Cell range [Dec 1, Jan 1) (31 days) → labelled Nov. 2025
- Cell range [Jan 1, Feb 1) (31 days) → labelled Dez. 2025
- Cell range [Feb 1, Mar 1) (28 days) → labelled Jan. 2026
- ...
Day-of-month labels are off by one in the same way. The cell ranges (which use $cal.toMillis() directly) remain correct, so the user sees a month box that is several days wider or narrower than the day boxes labelled for "that" month.
Root cause
The chain is:
- Calendar carries an instant plus its own TimeZone (TopLogic's configured system TZ via toSystemCalendar()).
- Calendar.toMillis() returns the absolute UTC millis at that instant.
- .toDate() wraps those millis as java.util.Date, which carries no timezone.
- SimpleDateFormat.format(date) renders the millis in the format's TimeZone, which defaulted to TimeZone.getDefault() (JVM default) instead of the user's/system timezone.
If step 4's timezone is not the same as step 1's, the rendered text describes the same instant from a different timezone, and on a midnight-aligned Calendar value (e.g. the first of a month) the displayed date crosses a day/month/year boundary.
Solution
dateFormat() no longer uses the JVM default timezone. The no-argument call now renders values in the current user's timezone (ThreadContext.getTimeZone()), consistent with how the locale is already resolved on the same line. This fixes the off-by-one for the common case without any explicit argument.
In addition, dateFormat() gained an optional timeZone argument that, when present, calls SimpleDateFormat.setTimeZone(...) on the returned format. The argument accepts a TimeZone instance, a Calendar (its own TimeZone is used), or a zone-id string. Two zone-ids are treated specially: "system" resolves to TimeZones.systemTimeZone() and "user" resolves to ThreadContext.getTimeZone().
{{{#!java public class DateFormatExpr extends GenericMethod {
@Override
protected Object eval(Object[] arguments, EvalContext definitions) {
if (arguments[0] == null) {
return null;
}
SimpleDateFormat format =
new SimpleDateFormat(asString(arguments[0]), ThreadContext.getLocale());
format.setTimeZone(arguments[1] != null ? asTimeZone(arguments[1]) : ThreadContext.getTimeZone());
return format;
}
public static final class Builder extends AbstractSimpleMethodBuilder<DateFormatExpr> {
private static final ArgumentDescriptor DESCRIPTOR = ArgumentDescriptor.builder()
.mandatory("pattern")
.optional("timeZone")
.build();
// ...
}
} }}}
A complementary [calendar].timeZone() accessor returns the Calendar's own TimeZone, so scripts can pass a Calendar's timezone straight to dateFormat() without hardcoding a zone id. With this, the Gantt demo's labels become:
yearFmt = dateFormat("yyyy", $startCal.timeZone());
monthFmt = dateFormat("MMM", $startCal.timeZone());
monthYearFmt = dateFormat("MMM yyyy", $startCal.timeZone());
dayFmt = dateFormat("dd", $startCal.timeZone());
and the withHour(12) workaround previously in com.top_logic.demo/src/main/webapp/WEB-INF/views/demo/gantt-demo.view.xml (committed under #29108) was removed.
Notes
- The same issue affected any user of dateFormat(...).format($cal.toDate()) in TL Script — this is not specific to the Gantt demo, just the place where it became visible.
Migration
The default timezone of TL Script dateFormat() changed from the JVM default timezone to the current user's timezone (ThreadContext.getTimeZone()). Scripts that format instants without an explicit timeZone argument now render them in the user's timezone rather than the server's JVM default. This is the correct behavior and matches how the locale is already resolved; scripts that need a specific timezone can pass it explicitly (a zone-id, "system", "user", or a calendar's timeZone()).