diff --git a/Dockerfile b/Dockerfile index 21eabdcb..80374059 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ ENV MAX_HEAP_PERCENTAGE=70 COPY ./server/target/ogcapi-java-server-*-exec.jar app.jar ENTRYPOINT ["/bin/sh", "-c", "java \ -XX:MaxRAMPercentage=${MAX_HEAP_PERCENTAGE} \ + -Duser.timezone=UTC \ -Delasticsearch.index.name=${INDEX_NAME} \ -Delasticsearch.cloud_optimized_index.name=${CO_INDEX_NAME} \ -Delasticsearch.vocabs_index.name=${VOCABS_INDEX_NAME} \ diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java index 980d1eb3..ffec5bfc 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java @@ -1,6 +1,12 @@ package au.org.aodn.ogcapi.server.core.util; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.YearMonth; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.Locale; import java.util.regex.Pattern; @@ -8,9 +14,11 @@ public class DatetimeUtils { private static final Pattern MM_YYYY_PATTERN = Pattern.compile("^(\\d{2})-(\\d{4})$"); private static final Pattern YYYY_MM_DD_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); private static final DateTimeFormatter ISO_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - // Email display format (dd MMM yyyy), e.g. 05 Jan 2024; English so the month is stable across locales - private static final DateTimeFormatter DISPLAY_DATE_FORMAT = - DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH); + private static final DateTimeFormatter MONTH_YEAR_FORMAT = DateTimeFormatter.ofPattern("MM-yyyy"); + // Email display format with an explicit UTC indicator; English keeps month names stable across locales. + private static final DateTimeFormatter DISPLAY_UTC_DATE_TIME_FORMAT = + DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm:ss 'UTC'", Locale.ENGLISH) + .withZone(ZoneOffset.UTC); public static final String NON_SPECIFIED_DATE = "non-specified"; // Frontend default lower bound (dateDefault.min); its default upper bound is "now" public static final String DEFAULT_MIN_DATE = "1970-01-01"; @@ -39,24 +47,52 @@ public static boolean isOpenUpperBound(String date) { return true; } try { - return !java.time.LocalDate.parse(date.trim(), ISO_DATE_FORMAT).isBefore(java.time.LocalDate.now()); - } catch (java.time.format.DateTimeParseException e) { + return !LocalDate.parse(date.trim(), ISO_DATE_FORMAT).isBefore(LocalDate.now()); + } catch (DateTimeParseException e) { return false; } } /** - * Format an ISO date (yyyy-MM-dd) for display as dd MMM yyyy, e.g. 05 Jan 2024. - * Returns "" for null/empty/non-specified, and falls back to the raw input if it cannot be parsed. + * Format a download/subsetting boundary in UTC for display in notification emails. + * A date-only value is the inclusive UTC day processed by the backend, displayed + * at whole-second precision from 00:00:00 UTC through 23:59:59 UTC. A month-only + * value uses the equivalent boundaries for its first and last days. An ISO-8601 + * timestamp with an offset is converted to the same instant in UTC. This method is + * deliberately specific to download filters; Tiler date keys are opaque calendar + * keys and must not be passed through it. + * + * @return an explicit UTC timestamp, an empty string for an unspecified value, or + * the unmodified input when it is not a supported temporal value */ - public static String toDisplayDate(String isoDate) { - if (isoDate == null || isoDate.trim().isEmpty() || NON_SPECIFIED_DATE.equalsIgnoreCase(isoDate.trim())) { + public static String toDisplayUtcDownloadBoundary(String value, boolean endBoundary) { + if (value == null || value.trim().isEmpty() || NON_SPECIFIED_DATE.equalsIgnoreCase(value.trim())) { return ""; } + + String trimmed = value.trim(); try { - return java.time.LocalDate.parse(isoDate.trim(), ISO_DATE_FORMAT).format(DISPLAY_DATE_FORMAT); - } catch (java.time.format.DateTimeParseException e) { - return isoDate; + LocalDate date = LocalDate.parse(trimmed, ISO_DATE_FORMAT); + return DISPLAY_UTC_DATE_TIME_FORMAT.format(endBoundary + ? date.atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC) + : date.atStartOfDay().toInstant(ZoneOffset.UTC)); + } catch (DateTimeParseException ignored) { + // It may be a month or an ISO-8601 timestamp with an explicit offset. + } + + try { + YearMonth month = YearMonth.parse(trimmed, MONTH_YEAR_FORMAT); + return DISPLAY_UTC_DATE_TIME_FORMAT.format(endBoundary + ? month.atEndOfMonth().atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC) + : month.atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC)); + } catch (DateTimeParseException ignored) { + // It may already be an ISO-8601 timestamp with an explicit offset. + } + + try { + return DISPLAY_UTC_DATE_TIME_FORMAT.format(OffsetDateTime.parse(trimmed).toInstant()); + } catch (DateTimeParseException ignored) { + return value; } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java index 0a15a09b..95467a9d 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java @@ -49,9 +49,10 @@ public static String generateSubsettingSection( ObjectMapper objectMapper ) { try { - // Format dates for display as dd MMM yyyy, e.g. 05 Jan 2024 - String displayStartDate = DatetimeUtils.toDisplayDate(startDate); - String displayEndDate = DatetimeUtils.toDisplayDate(endDate); + // Download dates are UTC temporal filters (not Tiler calendar keys). Show + // the same inclusive day boundaries that the backend processes. + String displayStartDate = DatetimeUtils.toDisplayUtcDownloadBoundary(startDate, false); + String displayEndDate = DatetimeUtils.toDisplayUtcDownloadBoundary(endDate, true); // The frontend default full range (1970-01-01 to now) means "no temporal subset" - treat as unspecified boolean isOpenRange = DatetimeUtils.isDefaultLowerBound(startDate) && DatetimeUtils.isOpenUpperBound(endDate); diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/ServerTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/ServerTest.java new file mode 100644 index 00000000..d1fda477 --- /dev/null +++ b/server/src/test/java/au/org/aodn/ogcapi/server/ServerTest.java @@ -0,0 +1,25 @@ +package au.org.aodn.ogcapi.server; + +import org.junit.jupiter.api.Test; + +import java.time.ZoneId; +import java.util.TimeZone; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ServerTest { + + @Test + void initSetsUtcAsDefaultTimezone() { + TimeZone originalTimeZone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Australia/Sydney")); + + new Server().init(); + + assertEquals(ZoneId.of("UTC"), ZoneId.systemDefault()); + } finally { + TimeZone.setDefault(originalTimeZone); + } + } +} diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java index c9418653..03162fbf 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtilsTest.java @@ -9,6 +9,36 @@ */ public class DatetimeUtilsTest { + @Test + public void testDisplayUtcDownloadBoundary_DateOnlyUsesInclusiveDayLimits() { + assertEquals("05 Jan 2024 00:00:00 UTC", + DatetimeUtils.toDisplayUtcDownloadBoundary("2024-01-05", false)); + assertEquals("05 Jan 2024 23:59:59 UTC", + DatetimeUtils.toDisplayUtcDownloadBoundary("2024-01-05", true)); + } + + @Test + public void testDisplayUtcDownloadBoundary_MonthUsesInclusiveMonthLimits() { + assertEquals("01 Feb 2024 00:00:00 UTC", + DatetimeUtils.toDisplayUtcDownloadBoundary("02-2024", false)); + assertEquals("29 Feb 2024 23:59:59 UTC", + DatetimeUtils.toDisplayUtcDownloadBoundary("02-2024", true)); + } + + @Test + public void testDisplayUtcDownloadBoundary_NormalizesOffsetTimestamp() { + assertEquals("05 Jan 2024 00:30:00 UTC", + DatetimeUtils.toDisplayUtcDownloadBoundary("2024-01-05T11:30:00+11:00", false)); + assertEquals("05 Jan 2024 00:30:00 UTC", + DatetimeUtils.toDisplayUtcDownloadBoundary("2024-01-05T00:30:00.123456789Z", true)); + } + + @Test + public void testDisplayUtcDownloadBoundary_OmitsUnspecifiedValues() { + assertEquals("", DatetimeUtils.toDisplayUtcDownloadBoundary("non-specified", false)); + assertEquals("", DatetimeUtils.toDisplayUtcDownloadBoundary(null, true)); + } + @Test public void testValidateAndFormatDate_ValidYYYY_MM_DD() { // Test valid YYYY-MM-DD format diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java index eb2c63e4..ea604806 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java @@ -262,15 +262,16 @@ void testOnlyDatesShowsSection() { } /** - * Test that dates are shown as dd MMM yyyy, e.g. 05 Jan 2024 + * Date-only download filters represent inclusive UTC day boundaries. */ @Test - void testDatesUseDayMonthYearFormat() { + void testDatesShowInclusiveUtcRange() { String result = EmailUtils.generateSubsettingSection( "2024-01-05", "2024-12-31", null, new ObjectMapper() ); - assertTrue(result.contains("05 Jan 2024 - 31 Dec 2024"), "Expected dd MMM yyyy date format"); + assertTrue(result.contains( + "05 Jan 2024 00:00:00 UTC - 31 Dec 2024 23:59:59 UTC")); } /** @@ -298,7 +299,7 @@ void testDefaultLowerBoundWithRealEndShowsSection() { assertFalse(result.isEmpty()); assertTrue(result.contains("Date Range")); - assertTrue(result.contains("01 Jan 1970 - 31 Dec 2024")); + assertTrue(result.contains("01 Jan 1970 00:00:00 UTC - 31 Dec 2024 23:59:59 UTC")); } /**