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