minor
#29306
TL Script: dateFormat() should accept an explicit timezone to format Calendar values losslessly
Problem
Formatting a Calendar value using dateFormat(...).format($cal.toDate()) was not lossless when the TopLogic system or user time zone differed from the JVM default time zone. The label could shift by one day at midnight, and by an entire month or year at month/year boundaries.
The root cause is that `dateFormat()` rendered values in the JVM default time zone (TimeZone.getDefault())—unlike the locale, which it already obtained from `ThreadContext.getLocale()`. The JVM default time zone should never be used in application code.
Reproduction (Gantt demo, #29108)
Set the application’s system time zone to be more than one day’s distance east of the JVM default (e.g., system time zone = Asia/Tokyo, JVM default = UTC). The Gantt demo’s axis labels will then appear one period early:
- Cell range [Dec 1, Jan 1) (31 days) → labeled Nov. 2025
- Cell range [Jan 1, Feb 1) (31 days) → labeled Dec. 2025
- Cell range [Feb 1, Mar 1) (28 days) → labeled 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 labeled for “that” month.
Root cause
The sequence is:
- Calendar carries a timestamp plus its own time zone (TopLogic’s configured system time zone via toSystemCalendar()).
- Calendar.toMillis() returns the absolute UTC millis at that instant.
- .toDate() wraps those millis as a` java.util.Date`, which carries no time zone.
- SimpleDateFormat.format(date) renders the milliseconds in the format’s time zone, which defaults to TimeZone.getDefault() (the JVM default) instead of the user’s or system’s time zone.
If the time zone in step 4 is not the same as the one in step 1, the rendered text describes the same instant from a different time zone, and for a Calendar value aligned to midnight (e.g., the first of the month), the displayed date crosses a day/month/year boundary.
Solution
dateFormat() no longer uses the JVM default time zone. The no-argument call now renders values in the current user’s time zone (ThreadContext.getTimeZone()), consistent with how the locale is already resolved on the same line. This fixes the off-by-one issue for the common case without any explicit argument.
In addition, `dateFormat()` now includes an optional `timeZone` argument that, when provided, calls `SimpleDateFormat.setTimeZone(...)` on the returned format. The argument accepts a `TimeZone` instance, a `Calendar ` (which uses its own `TimeZone` ), 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 time zone, so scripts can pass a Calendar's time zone directly to dateFormat() without hard-coding 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 found 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 time zone for TL Script’s `dateFormat()` has changed from the JVM’s default time zone to the current user’s time zone (ThreadContext.getTimeZone()). Scripts that format timestamps without an explicit `timeZone` argument now render them in the user’s time zone rather than the server’s JVM default. This is the correct behavior and aligns with how the locale is already resolved; scripts that require a specific time zone can specify it explicitly (a zone ID, "system, " "user, " or a calendar’s timeZone()).