diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/DateTimeGroupBy.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/DateTimeGroupBy.java
new file mode 100644
index 00000000..bbd18e47
--- /dev/null
+++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/DateTimeGroupBy.java
@@ -0,0 +1,249 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2023. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.actions.dashboard.widgets;
+
+
+import java.time.DayOfWeek;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.IsoFields;
+import java.time.temporal.TemporalAdjusters;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+
+
+/*******************************************************************************
+ ** Enum to define various "levels" of group-by for on dashboards that want to
+ ** group records by, e.g., year, or month, or week, or day, or hour.
+ *******************************************************************************/
+public enum DateTimeGroupBy
+{
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // note - double %'s on the time format strings here, because this is a java-format string, which will get //
+ // its '%s' replaced with a column name, and so then those %'s for the date_format need escaped as %%. //
+ // See https://www.w3schools.com/sql/func_mysql_date_format.asp for DATE_FORMAT args //
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ YEAR("%%Y", MillisPer.YEAR, 1, ChronoUnit.YEARS, DateTimeFormatter.ofPattern("yyyy"), DateTimeFormatter.ofPattern("yyyy")),
+ MONTH("%%Y-%%m", 2 * MillisPer.MONTH, 1, ChronoUnit.MONTHS, DateTimeFormatter.ofPattern("yyyy-MM"), DateTimeFormatter.ofPattern("MMM'.' yyyy")),
+ WEEK("%%XW%%V", 35 * MillisPer.DAY, 7, ChronoUnit.DAYS, DateTimeFormatter.ofPattern("YYYY'W'ww"), DateTimeFormatter.ofPattern("YYYY'W'w")),
+ DAY("%%Y-%%m-%%d", 36 * MillisPer.HOUR, 1, ChronoUnit.DAYS, DateTimeFormatter.ofPattern("yyyy-MM-dd"), DateTimeFormatter.ofPattern("EEE'.' M'/'d")),
+ HOUR("%%Y-%%m-%%dT%%H", 0, 1, ChronoUnit.HOURS, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH"), DateTimeFormatter.ofPattern("h a"));
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public interface MillisPer
+ {
+ long HOUR = 60 * 60 * 1000;
+ long DAY = 24 * HOUR;
+ long WEEK = 7 * DAY;
+ long MONTH = 30 * DAY;
+ long YEAR = 365 * DAY;
+ }
+
+
+
+ private final String sqlDateFormat;
+ private final long millisThreshold;
+ private final int noOfChronoUnitsToAdd;
+ private final ChronoUnit chronoUnitToAdd;
+ private final DateTimeFormatter selectedStringFormatter;
+ private final DateTimeFormatter humanStringFormatter;
+
+
+
+ /*******************************************************************************
+ ** Constructor
+ **
+ *******************************************************************************/
+ DateTimeGroupBy(String sqlDateFormat, long millisThreshold, int noOfChronoUnitsToAdd, ChronoUnit chronoUnitToAdd, DateTimeFormatter selectedStringFormatter, DateTimeFormatter humanStringFormatter)
+ {
+ this.sqlDateFormat = sqlDateFormat;
+ this.millisThreshold = millisThreshold;
+ this.noOfChronoUnitsToAdd = noOfChronoUnitsToAdd;
+ this.chronoUnitToAdd = chronoUnitToAdd;
+ this.selectedStringFormatter = selectedStringFormatter;
+ this.humanStringFormatter = humanStringFormatter;
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public String getSqlExpression()
+ {
+ ZoneId sessionOrInstanceZoneId = ValueUtils.getSessionOrInstanceZoneId();
+ String targetTimezone = sessionOrInstanceZoneId.toString();
+
+ if("Z".equals(targetTimezone) || !StringUtils.hasContent(targetTimezone))
+ {
+ targetTimezone = "UTC";
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ // if we only had a timezone offset (not a zone name/id), then the zoneId's toString will look like //
+ // UTC-05:00. MySQL doesn't want that, so, strip away the leading UTC, to just get -05:00 //
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ if((targetTimezone.startsWith("UTC-") || targetTimezone.startsWith("UTC+")) && targetTimezone.length() > 5)
+ {
+ targetTimezone = targetTimezone.substring(3);
+ }
+
+ return "DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '" + targetTimezone + "'), '" + sqlDateFormat + "')";
+
+ /*
+ if(this == WEEK)
+ {
+ return "YEARWEEK(CONVERT_TZ(%s, 'UTC', '" + targetTimezone + "'), 6)";
+ }
+ else
+ {
+ return "DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '" + targetTimezone + "'), '" + sqlDateFormat + "')";
+ }
+ */
+ }
+
+
+
+ /*******************************************************************************
+ ** get an instance of this enum, based on start & end instants - look at the #
+ ** of millis between them, and return the first enum value w/ a millisThreshold
+ ** under that difference. Default to HOUR.
+ *******************************************************************************/
+ public static DateTimeGroupBy selectFromStartAndEndTimes(Instant start, Instant end)
+ {
+ long millisBetween = end.toEpochMilli() - start.toEpochMilli();
+ for(DateTimeGroupBy value : DateTimeGroupBy.values())
+ {
+ if(millisBetween > value.millisThreshold)
+ {
+ return (value);
+ }
+ }
+
+ return (HOUR);
+ }
+
+
+
+ /*******************************************************************************
+ ** Make an Instant into a string that will match what came out of the database's
+ ** DATE_FORMAT() function
+ *******************************************************************************/
+ public String makeSelectedString(Instant time)
+ {
+ ZonedDateTime zoned = time.atZone(ValueUtils.getSessionOrInstanceZoneId());
+
+ if(this == WEEK)
+ {
+ ////////////////////////////////////////////////////////////////////////////////////////////////////
+ // so, it seems like database is returning, e.g., W00-W52, but java is doing W1-W53... //
+ // which, apparently we can compensate for by adding a week? not sure, but results seemed right. //
+ ////////////////////////////////////////////////////////////////////////////////////////////////////
+ zoned = zoned.plusDays(7);
+ int weekYear = zoned.get(IsoFields.WEEK_BASED_YEAR);
+ int week = zoned.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
+ return (String.format("%04dW%02d", weekYear, week));
+ }
+
+ return (selectedStringFormatter.format(zoned));
+ }
+
+
+
+ /*******************************************************************************
+ ** Make a string to show to a user
+ *******************************************************************************/
+ public String makeHumanString(Instant instant)
+ {
+ ZonedDateTime zoned = instant.atZone(ValueUtils.getSessionOrInstanceZoneId());
+ if(this.equals(WEEK))
+ {
+ DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("M'/'d");
+
+ while(zoned.get(ChronoField.DAY_OF_WEEK) != DayOfWeek.SUNDAY.getValue())
+ {
+ ////////////////////////////////////////
+ // go backwards until sunday is found //
+ ////////////////////////////////////////
+ zoned = zoned.minus(1, ChronoUnit.DAYS);
+ }
+
+ return (dateTimeFormatter.format(zoned) + "-" + dateTimeFormatter.format(zoned.plusDays(6)));
+
+ /*
+ int weekOfYear = zoned.get(ChronoField.ALIGNED_WEEK_OF_YEAR);
+ ZonedDateTime sunday = zoned.with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, weekOfYear).with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY));
+ ZonedDateTime saturday = sunday.with(TemporalAdjusters.next(DayOfWeek.SATURDAY));
+ DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("M'/'d");
+
+ return (dateTimeFormatter.format(sunday) + "-" + dateTimeFormatter.format(saturday));
+ */
+ }
+
+ return (humanStringFormatter.format(zoned));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @SuppressWarnings("checkstyle:indentation")
+ public Instant roundDown(Instant instant)
+ {
+ ZonedDateTime zoned = instant.atZone(ValueUtils.getSessionOrInstanceZoneId());
+ return switch(this)
+ {
+ case YEAR -> zoned.with(TemporalAdjusters.firstDayOfYear()).truncatedTo(ChronoUnit.DAYS).toInstant();
+ case MONTH -> zoned.with(TemporalAdjusters.firstDayOfMonth()).truncatedTo(ChronoUnit.DAYS).toInstant();
+ case WEEK ->
+ {
+ while(zoned.get(ChronoField.DAY_OF_WEEK) != DayOfWeek.SUNDAY.getValue())
+ {
+ zoned = zoned.minusDays(1);
+ }
+ yield (zoned.truncatedTo(ChronoUnit.DAYS).toInstant());
+ }
+ case DAY -> zoned.truncatedTo(ChronoUnit.DAYS).toInstant();
+ case HOUR -> zoned.truncatedTo(ChronoUnit.HOURS).toInstant();
+ };
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public Instant increment(Instant instant)
+ {
+ ZonedDateTime zoned = instant.atZone(ValueUtils.getSessionOrInstanceZoneId());
+ return (zoned.plus(noOfChronoUnitsToAdd, chronoUnitToAdd).toInstant());
+ }
+}
diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/DateTimeGroupByTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/DateTimeGroupByTest.java
new file mode 100644
index 00000000..7af52c19
--- /dev/null
+++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/DateTimeGroupByTest.java
@@ -0,0 +1,241 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2023. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.core.actions.dashboard.widgets;
+
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.function.BiFunction;
+import com.kingsrook.qqq.backend.core.BaseTest;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+
+/*******************************************************************************
+ ** Unit test for DateTimeGroupBy
+ *******************************************************************************/
+class DateTimeGroupByTest extends BaseTest
+{
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testMillisPer()
+ {
+ assertEquals(3_600_000L, DateTimeGroupBy.MillisPer.HOUR);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testRoundDown()
+ {
+ assertEquals(Instant.parse("2023-01-01T00:00:00Z"), DateTimeGroupBy.YEAR.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
+ assertEquals(Instant.parse("2023-03-01T00:00:00Z"), DateTimeGroupBy.MONTH.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
+ assertEquals(Instant.parse("2023-03-12T00:00:00Z"), DateTimeGroupBy.WEEK.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
+ assertEquals(Instant.parse("2023-03-13T00:00:00Z"), DateTimeGroupBy.DAY.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
+ assertEquals(Instant.parse("2023-03-13T14:00:00Z"), DateTimeGroupBy.HOUR.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
+
+ QContext.getQInstance().setDefaultTimeZoneId("US/Eastern");
+ assertEquals(Instant.parse("2023-02-01T05:00:00Z"), DateTimeGroupBy.MONTH.roundDown(Instant.parse("2023-03-01T00:00:00Z")));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testIncrement()
+ {
+ assertEquals(Instant.parse("2024-03-01T00:00:00Z"), DateTimeGroupBy.YEAR.increment(Instant.parse("2023-03-01T00:00:00Z")));
+ assertEquals(Instant.parse("2024-01-01T00:00:00Z"), DateTimeGroupBy.MONTH.increment(Instant.parse("2023-12-01T00:00:00Z")));
+ assertEquals(Instant.parse("2024-01-01T00:00:00Z"), DateTimeGroupBy.MONTH.increment(Instant.parse("2023-12-01T00:00:00Z")));
+ assertEquals(Instant.parse("2023-03-08T00:00:00Z"), DateTimeGroupBy.WEEK.increment(Instant.parse("2023-03-01T00:00:00Z")));
+ assertEquals(Instant.parse("2023-03-02T00:00:00Z"), DateTimeGroupBy.DAY.increment(Instant.parse("2023-03-01T00:00:00Z")));
+ assertEquals(Instant.parse("2023-03-01T01:00:00Z"), DateTimeGroupBy.HOUR.increment(Instant.parse("2023-03-01T00:00:00Z")));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testSelectFromStartAndEndTimes()
+ {
+ ///////////////////////////////////////////////////////////////////
+ // simple wrapper to call the function w/ a specified time range //
+ ///////////////////////////////////////////////////////////////////
+ BiFunction f = (amountToAdd, unit) ->
+ {
+ Instant start = Instant.parse("2021-01-01T00:00:00Z");
+ Instant end = start.plus(amountToAdd, unit);
+ return (DateTimeGroupBy.selectFromStartAndEndTimes(start, end));
+ };
+
+ ///////////////////////////////////////////////////////////////////
+ // choose YEAR if the timeframe is any amount larger than a year //
+ ///////////////////////////////////////////////////////////////////
+ assertEquals(DateTimeGroupBy.YEAR, f.apply(365 * 10, ChronoUnit.DAYS));
+ assertEquals(DateTimeGroupBy.YEAR, f.apply(365 + 1, ChronoUnit.DAYS));
+
+ /////////////////////////////////////////////////////////
+ // choose month if equal to 1 year, or down to 60 days //
+ /////////////////////////////////////////////////////////
+ assertEquals(DateTimeGroupBy.MONTH, f.apply(365, ChronoUnit.DAYS));
+ assertEquals(DateTimeGroupBy.MONTH, f.apply(61, ChronoUnit.DAYS));
+
+ ///////////////////////////////////////
+ // week between 60 days and 35 days //
+ ///////////////////////////////////////
+ assertEquals(DateTimeGroupBy.WEEK, f.apply(60, ChronoUnit.DAYS));
+ assertEquals(DateTimeGroupBy.WEEK, f.apply(36, ChronoUnit.DAYS));
+
+ //////////////////////////////////////
+ // day between 35 days and 36 hours //
+ //////////////////////////////////////
+ assertEquals(DateTimeGroupBy.DAY, f.apply(35, ChronoUnit.DAYS));
+ assertEquals(DateTimeGroupBy.DAY, f.apply(37, ChronoUnit.HOURS));
+
+ //////////////////////////////////////////
+ // hour under 36 hours (even negative!) //
+ //////////////////////////////////////////
+ assertEquals(DateTimeGroupBy.HOUR, f.apply(35, ChronoUnit.HOURS));
+ assertEquals(DateTimeGroupBy.HOUR, f.apply(1, ChronoUnit.HOURS));
+ assertEquals(DateTimeGroupBy.HOUR, f.apply(0, ChronoUnit.HOURS));
+ assertEquals(DateTimeGroupBy.HOUR, f.apply(-1, ChronoUnit.HOURS));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testGetSqlExpression()
+ {
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // note - double %'s on the time format strings here, because this is a java-format string, which will get //
+ // its '%s' replaced with a column name, and so then those %'s for the date_format need escaped as %%. //
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'UTC'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'UTC'), '%%Y-%%m')", DateTimeGroupBy.MONTH.getSqlExpression());
+
+ /////////////////////////////////////////////////////////////////////////////
+ // if session has no zone info, but instance does, assert that it is used. //
+ /////////////////////////////////////////////////////////////////////////////
+ QContext.getQInstance().setDefaultTimeZoneId("US/Eastern");
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Eastern'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
+
+ QContext.getQInstance().setDefaultTimeZoneId("US/Central");
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Central'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
+
+ //////////////////////////////////////////////////////////////////
+ // put a zone offset (but not name) in session - see it be used //
+ //////////////////////////////////////////////////////////////////
+ QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES, "-300");
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '-05:00'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
+
+ QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES, "-420");
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '-07:00'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
+
+ ///////////////////////////////////////////////////
+ // put a zone (name) in session - see it be used //
+ ///////////////////////////////////////////////////
+ QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES, null);
+ QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE, "US/Central");
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Central'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
+
+ QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE, "US/Eastern");
+ assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Eastern'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testMakeSelectedString()
+ {
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-12-31T11:59:59Z")));
+
+ ///////////////////////////////////////////////
+ // make sure a timezone does what's expected //
+ ///////////////////////////////////////////////
+ QContext.getQInstance().setDefaultTimeZoneId("US/Central");
+ assertEquals("2020", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-01-01T06:00:00Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-12-31T11:59:59Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2022-01-01T03:00:00Z")));
+
+ ///////////////////////////////////////////////
+ // reset to UTC - test the other enum values //
+ ///////////////////////////////////////////////
+ QContext.getQInstance().setDefaultTimeZoneId("UTC");
+ assertEquals("2021-01", DateTimeGroupBy.MONTH.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("2021W01", DateTimeGroupBy.WEEK.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("2021W01", DateTimeGroupBy.WEEK.makeSelectedString(Instant.parse("2020-12-31T00:00:00Z")));
+ assertEquals("2021-01-01", DateTimeGroupBy.DAY.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("2021-01-01T00", DateTimeGroupBy.HOUR.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Test
+ void testMakeHumanString()
+ {
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-12-31T11:59:59Z")));
+
+ ///////////////////////////////////////////////
+ // make sure a timezone does what's expected //
+ ///////////////////////////////////////////////
+ QContext.getQInstance().setDefaultTimeZoneId("US/Central");
+ assertEquals("2020", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-01-01T06:00:00Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-12-31T11:59:59Z")));
+ assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2022-01-01T03:00:00Z")));
+
+ ///////////////////////////////////////////////
+ // reset to UTC - test the other enum values //
+ ///////////////////////////////////////////////
+ QContext.getQInstance().setDefaultTimeZoneId("UTC");
+ assertEquals("Jan. 2021", DateTimeGroupBy.MONTH.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("12/27-1/2", DateTimeGroupBy.WEEK.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("12/27-1/2", DateTimeGroupBy.WEEK.makeHumanString(Instant.parse("2020-12-31T00:00:00Z")));
+ assertEquals("Fri. 1/1", DateTimeGroupBy.DAY.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
+ assertEquals("12 AM", DateTimeGroupBy.HOUR.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
+ }
+
+}
\ No newline at end of file