From 04da0f8aea4f5ac869fdff95cb32b8e8d9ed357b Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Wed, 16 Sep 2026 21:21:37 -0700 Subject: [PATCH] Fixed the end-of-day boundary on clock-change days and sent calendar times without a zone * TimeUtils.makeEndOfDay_12AM() adds a calendar day instead of 24 hours. It sets the end of the date range in the billing exports and the scheduled-time pages. For an end date on a clock-change Sunday it returned 1 AM or 11 PM instead of midnight. * JSONInstrumentUsageGetter sends block start and end times to the calendar as yyyy-MM-dd'T'HH:mm:ss instead of Date.toString(). A browser outside Pacific time displayed blocks at a different hour than their labels. * Documented that UsageBlockBase.getHours() counts elapsed hours, one less or more than the clock times across a daylight saving change. * Removed the commented-out TimeUtils.makeEndOfDay(). * Added UsageBlockBaseTest, TimeUtilsTest and JSONInstrumentUsageGetterTest. Co-Authored-By: Claude --- .../uwpr/instrumentlog/UsageBlockBase.java | 4 ++ .../instrumentlog/UsageBlockBaseTest.java | 65 +++++++++++++++++++ .../JSONInstrumentUsageGetter.java | 14 +++- .../JSONInstrumentUsageGetterTest.java | 64 ++++++++++++++++++ src/org/uwpr/www/util/TimeUtils.java | 19 +----- src/org/uwpr/www/util/TimeUtilsTest.java | 54 +++++++++++++++ 6 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 src/org/uwpr/instrumentlog/UsageBlockBaseTest.java create mode 100644 src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetterTest.java create mode 100644 src/org/uwpr/www/util/TimeUtilsTest.java diff --git a/src/org/uwpr/instrumentlog/UsageBlockBase.java b/src/org/uwpr/instrumentlog/UsageBlockBase.java index d2b556d..7b43e2a 100644 --- a/src/org/uwpr/instrumentlog/UsageBlockBase.java +++ b/src/org/uwpr/instrumentlog/UsageBlockBase.java @@ -188,6 +188,10 @@ public void setSetupBlock(boolean setupBlock) { this.setupBlock = setupBlock; } + /** + * Returns the elapsed hours from start to end. Across a daylight saving change this is one hour less or + * more than the clock times. Billing, the instrument time quota and the scheduled time totals use this count. + */ public int getHours() { return TimeUtils.getHours(getStartDate(), getEndDate()); diff --git a/src/org/uwpr/instrumentlog/UsageBlockBaseTest.java b/src/org/uwpr/instrumentlog/UsageBlockBaseTest.java new file mode 100644 index 0000000..79a3bb1 --- /dev/null +++ b/src/org/uwpr/instrumentlog/UsageBlockBaseTest.java @@ -0,0 +1,65 @@ +package org.uwpr.instrumentlog; + +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; + +import junit.framework.TestCase; + +/** + * getHours() must count elapsed hours. A block across a daylight saving change counts one hour less or more + * than its clock times. + */ +public class UsageBlockBaseTest extends TestCase { + + private TimeZone defaultZone; + + protected void setUp() throws Exception { + super.setUp(); + defaultZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")); + } + + protected void tearDown() throws Exception { + TimeZone.setDefault(defaultZone); + super.tearDown(); + } + + public final void testHoursWithNoClockChange() { + UsageBlockBase block = block(2026, 9, 16, 22, 2026, 9, 17, 6); + assertEquals("10 PM to 6 AM with no clock change should be 8 hours", 8, block.getHours()); + } + + public final void testHoursAcrossSpringForward() { + // Clocks go from 2 AM to 3 AM on 2026-03-08. + UsageBlockBase block = block(2026, 3, 7, 22, 2026, 3, 8, 6); + assertEquals("10 PM to 6 AM across spring forward should be 7 hours", 7, block.getHours()); + } + + public final void testHoursAcrossFallBack() { + // Clocks go from 2 AM back to 1 AM on 2026-11-01. + UsageBlockBase block = block(2026, 10, 31, 22, 2026, 11, 1, 6); + assertEquals("10 PM to 6 AM across fall back should be 9 hours", 9, block.getHours()); + } + + public final void testHoursOfAMultiDayBlockAcrossFallBack() { + // Block 24951 on the dev copy, billed 29 hours. + UsageBlockBase block = block(2025, 11, 1, 0, 2025, 11, 2, 4); + assertEquals("Midnight Nov 1 to 4 AM Nov 2 2025, across fall back, should be 29 hours", 29, block.getHours()); + } + + private static UsageBlockBase block(int startYear, int startMonth, int startDay, int startHour, + int endYear, int endMonth, int endDay, int endHour) { + UsageBlockBase block = new UsageBlockBase(); + block.setStartDate(date(startYear, startMonth, startDay, startHour)); + block.setEndDate(date(endYear, endMonth, endDay, endHour)); + return block; + } + + private static Date date(int year, int month, int day, int hour) { + Calendar calendar = Calendar.getInstance(); + calendar.clear(); + calendar.set(year, month - 1, day, hour, 0, 0); + return calendar.getTime(); + } +} diff --git a/src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetter.java b/src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetter.java index 826a308..3467b8b 100644 --- a/src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetter.java +++ b/src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetter.java @@ -276,8 +276,8 @@ private JSONObject getForContiguousBlocks(List blocks, event.put("projectId", Integer.valueOf(blocks.get(0).getProjectID())); event.put("instrumentId", Integer.valueOf(blocks.get(0).getInstrumentID())); event.put("title", String.valueOf(blocks.get(0).getProjectID())); - event.put("start", sd.toString()); - event.put("end", ed.toString()); + event.put("start", formatEventTime(sd)); + event.put("end", formatEventTime(ed)); event.put("allDay", Boolean.FALSE); event.put("editable", Boolean.FALSE); @@ -363,6 +363,16 @@ private JSONObject getForContiguousBlocks(List blocks, return event; } + /** + * Formats a block time for fullCalendar as the server's local date and time with no zone, for example + * 2026-09-17T10:00:00. fullCalendar displays a time with no zone at that hour in every browser time zone. + * Date.toString() carries the JVM's zone, and a browser in another time zone converts it to its own hour. + */ + static String formatEventTime(Date date) + { + return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date); + } + private Object addToCalenderLink(UsageBlock block) throws UnsupportedEncodingException { /* https://www.google.com/calendar/render?action=TEMPLATE&text=UWPR&dates=20140127T224000Z/20140320T221500Z&details=Some+Details&sf=true&output=xml diff --git a/src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetterTest.java b/src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetterTest.java new file mode 100644 index 0000000..334ac0a --- /dev/null +++ b/src/org/uwpr/www/instrumentlog/JSONInstrumentUsageGetterTest.java @@ -0,0 +1,64 @@ +package org.uwpr.www.instrumentlog; + +import java.time.Instant; +import java.util.Date; +import java.util.TimeZone; + +import junit.framework.TestCase; + +/** + * formatEventTime() must return a block time as the wall-clock time in the server's zone, the zone the block's + * label is formatted in, with no zone in the string. Each test starts from a fixed moment in UTC, so the + * expected hour depends on the server's zone. + */ +public class JSONInstrumentUsageGetterTest extends TestCase { + + private TimeZone defaultZone; + + protected void setUp() throws Exception { + super.setUp(); + defaultZone = TimeZone.getDefault(); + } + + protected void tearDown() throws Exception { + TimeZone.setDefault(defaultZone); + super.tearDown(); + } + + public final void testEventTimeOnALosAngelesServer() { + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")); + assertEquals("17:00 UTC on 2026-09-17 should be sent as 10:00 from a Los Angeles server (UTC - 7)", + "2026-09-17T10:00:00", JSONInstrumentUsageGetter.formatEventTime(utc("2026-09-17T17:00:00Z"))); + } + + public final void testEventTimeOnALosAngelesServerAfterFallBack() { + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")); + assertEquals("14:00 UTC on 2026-11-01, after the clocks go back, " + + "should be sent as 06:00 from a Los Angeles server (UTC - 8)", + "2026-11-01T06:00:00", JSONInstrumentUsageGetter.formatEventTime(utc("2026-11-01T14:00:00Z"))); + } + + public final void testEventTimeOnANewYorkServer() { + TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")); + assertEquals("17:00 UTC on 2026-09-17 should be sent as 13:00 from a New York server (UTC - 4)", + "2026-09-17T13:00:00", JSONInstrumentUsageGetter.formatEventTime(utc("2026-09-17T17:00:00Z"))); + } + + public final void testEventTimeOnAKolkataServer() { + // Kolkata has no daylight saving time and a half-hour offset from UTC. + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")); + assertEquals("17:00 UTC on 2026-09-17 should be sent as 22:30 from a Kolkata server (UTC + 5:30)", + "2026-09-17T22:30:00", JSONInstrumentUsageGetter.formatEventTime(utc("2026-09-17T17:00:00Z"))); + } + + public final void testEventTimeOnAServerWithAFixedOffset() { + // A fixed offset has no daylight saving time, so in September it is an hour behind Pacific time. + TimeZone.setDefault(TimeZone.getTimeZone("GMT-08:00")); + assertEquals("17:00 UTC on 2026-09-17 should be sent as 09:00 from a GMT-08:00 server (UTC - 8)", + "2026-09-17T09:00:00", JSONInstrumentUsageGetter.formatEventTime(utc("2026-09-17T17:00:00Z"))); + } + + private static Date utc(String isoInstant) { + return Date.from(Instant.parse(isoInstant)); + } +} diff --git a/src/org/uwpr/www/util/TimeUtils.java b/src/org/uwpr/www/util/TimeUtils.java index 7d5d8b0..0c73bb2 100644 --- a/src/org/uwpr/www/util/TimeUtils.java +++ b/src/org/uwpr/www/util/TimeUtils.java @@ -36,21 +36,6 @@ public static Date makeBeginningOfDay(Date date) return startCal.getTime(); } -// public static Date makeEndOfDay(Date date) -// { -// if(date == null) -// { -// return null; -// } -// Calendar endCal = Calendar.getInstance(); -// endCal.setTime(date); -// endCal.set(Calendar.MILLISECOND, 0); -// endCal.set(Calendar.SECOND, 0); -// endCal.set(Calendar.MINUTE, 0); -// endCal.set(Calendar.HOUR_OF_DAY, 0); // 12:00 am -// return new Date(endCal.getTime().getTime() + MILLIS_IN_DAY - 1); -// } - public static Date makeEndOfDay_12AM(Date date) { if(date == null) @@ -63,7 +48,9 @@ public static Date makeEndOfDay_12AM(Date date) endCal.set(Calendar.SECOND, 0); endCal.set(Calendar.MINUTE, 0); endCal.set(Calendar.HOUR_OF_DAY, 0); // 12:00 am - return new Date(endCal.getTime().getTime() + MILLIS_IN_DAY); + // Add a calendar day, not 24 hours. The day the clocks change is 23 or 25 hours long. + endCal.add(Calendar.DAY_OF_MONTH, 1); + return endCal.getTime(); } public static String format(Date date) diff --git a/src/org/uwpr/www/util/TimeUtilsTest.java b/src/org/uwpr/www/util/TimeUtilsTest.java new file mode 100644 index 0000000..62e2c91 --- /dev/null +++ b/src/org/uwpr/www/util/TimeUtilsTest.java @@ -0,0 +1,54 @@ +package org.uwpr.www.util; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; + +import junit.framework.TestCase; + +/** + * makeEndOfDay_12AM() is the end boundary of the billing export and the scheduled-time views, so it must be + * midnight at the start of the next day, including on a day when the clocks change. + */ +public class TimeUtilsTest extends TestCase { + + private TimeZone defaultZone; + + protected void setUp() throws Exception { + super.setUp(); + defaultZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")); + } + + protected void tearDown() throws Exception { + TimeZone.setDefault(defaultZone); + super.tearDown(); + } + + public final void testEndOfAnOrdinaryDay() { + assertEquals("The end of 2026-09-17 should be midnight on 2026-09-18", + "2026-09-18 00:00", format(TimeUtils.makeEndOfDay_12AM(date(2026, 9, 17, 10)))); + } + + public final void testEndOfSpringForwardDay() { + assertEquals("The end of 2026-03-08, when clocks go forward, should be midnight on 2026-03-09", + "2026-03-09 00:00", format(TimeUtils.makeEndOfDay_12AM(date(2026, 3, 8, 10)))); + } + + public final void testEndOfFallBackDay() { + assertEquals("The end of 2026-11-01, when clocks go back, should be midnight on 2026-11-02", + "2026-11-02 00:00", format(TimeUtils.makeEndOfDay_12AM(date(2026, 11, 1, 10)))); + } + + private static String format(Date date) { + return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(date); + } + + private static Date date(int year, int month, int day, int hour) { + Calendar calendar = Calendar.getInstance(); + calendar.clear(); + calendar.set(year, month - 1, day, hour, 0, 0); + return calendar.getTime(); + } +}