Skip to content

OBPIH-7559 allow APIs to accept date-only strings when binding Instants#5598

Merged
ewaterman merged 2 commits intodevelopfrom
bug/OBPIH-7559-product-list-date-filter
Nov 6, 2025
Merged

OBPIH-7559 allow APIs to accept date-only strings when binding Instants#5598
ewaterman merged 2 commits intodevelopfrom
bug/OBPIH-7559-product-list-date-filter

Conversation

@ewaterman
Copy link
Member

✨ Description of Change

Link to GitHub issue or Jira ticket: https://pihemr.atlassian.net/browse/OBPIH-7559

Description: Two fixes:

  1. The filters on the list product API needed to be modified to work with Instant (this was an easy fix)
  2. The React date picker on the product list page is only sending up a date (not date + time + zone) but binding to an Instant requires all three. This is less easy to solve. We need to either:
    a) Modify the API to default to the user’s timezone at midnight.
    b) Modify the date picker to send up a full ISO date (while still only displaying date?)

I chose to implement A because that's what I was familiar with, but I'm not certain that's the best solution. I don't like the idea of allowing the frontend to provide a date-only string to an Instant field because it requires the frontend to make assumptions about how the backend will handle that data (it has to assume that the backend will parse that as midnight in the user's time). I'd rather continue throwing an error in that scenario and require the frontend to pass the full ISO date (ie solution B) but I didn't feel confident enough to implement that into the date filter on the react side this close to release.

We should discuss this as a team and decide what we want to do


📷 Screenshots & Recordings (optional)

Before the fix:

2025-11-03.15-50-56.mp4

After:

Screenshot from 2025-11-03 14-05-34

@ewaterman ewaterman self-assigned this Nov 3, 2025
@github-actions github-actions bot added type: bug Addresses unintended behaviours of the app domain: backend Changes or discussions relating to the backend server labels Nov 3, 2025
Copy link
Member Author

@ewaterman ewaterman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm marking this PR as do not merge until we've had a chance to discuss. If you guys decide you want to merge it so QA can get a jump on testing it, I'm okay with that.


@Autowired
DateFormatterManager dateFormatter
DateFormatterManager dateFormatterManager
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated. I just realized that we can do away with the @Autowired if we make the variable the same as the class name

// 2) Modify the date picker on the react side to send a full date + time + zone string
ZoneId zone = sessionManager.timezone?.toZoneId()
Instant dateCreatedAfter = params.createdAfter ? DateUtil.asInstant(params.createdAfter, zone) : null
Instant dateCreatedBefore = params.createdBefore ? DateUtil.asInstant(params.createdBefore, zone) : null
Copy link
Member Author

@ewaterman ewaterman Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this solution long term, hence the TODO

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, just to note that I could have created an "asInstantInUserTimezone(String)" helper method somewhere but I chose not to because I don't want to make it easier to implement this in an "improper" way. When we do the date refactor, we should try to always do one or both of the steps mentioned in the TODO since that is the preferred approach.

@ewaterman ewaterman added the warn: do not merge Marks a pull request that is not yet ready to be merged label Nov 3, 2025
@codecov
Copy link

codecov bot commented Nov 3, 2025

Codecov Report

❌ Patch coverage is 38.46154% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 8.52%. Comparing base (1bb7314) to head (8fd738e).
⚠️ Report is 181 commits behind head on develop.

Files with missing lines Patch % Lines
grails-app/utils/org/pih/warehouse/DateUtil.groovy 36.84% 6 Missing and 6 partials ⚠️
...es/org/pih/warehouse/product/ProductService.groovy 20.00% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             develop   #5598      +/-   ##
============================================
- Coverage       9.12%   8.52%   -0.60%     
+ Complexity      1170    1127      -43     
============================================
  Files            701     712      +11     
  Lines          45281   45615     +334     
  Branches       10851   10914      +63     
============================================
- Hits            4131    3890     -241     
- Misses         40497   41146     +649     
+ Partials         653     579      -74     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ewaterman ewaterman removed the warn: do not merge Marks a pull request that is not yet ready to be merged label Nov 6, 2025
Copy link
Collaborator

@kchelstowski kchelstowski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the tests were really helpful in understanding the context, thanks

@ewaterman ewaterman merged commit abb31df into develop Nov 6, 2025
5 checks passed
@ewaterman ewaterman deleted the bug/OBPIH-7559-product-list-date-filter branch November 6, 2025 21:42
@alannadolny alannadolny requested a review from Copilot November 12, 2025 14:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR enables APIs to accept date-only strings (e.g., "2000-01-01") when binding to Instant fields by defaulting to midnight in the user's timezone. This change simplifies the migration from java.util.Date to java.time.Instant and fixes issues with the product list API filters.

Key changes:

  • Modified InstantValueConverter to accept date-only strings and default to midnight in user's timezone when timezone information is missing
  • Added DateUtil.asInstant(String, ZoneId) method to handle string-to-Instant conversion with fallback timezone support
  • Updated ProductService to properly handle Instant date filters using the user's timezone

Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
InstantValueConverter.groovy Delegates to DateUtil.asInstant() with user's timezone, enabling date-only string parsing
InstantValueConverterSpec.groovy Adds comprehensive test coverage for date-only string parsing with and without default timezone
DateUtil.groovy Implements new asInstant(String, ZoneId) and asLocalDate(String) methods, plus helper methods for timezone conversions
LocalDateValueConverter.groovy Refactored to delegate to DateUtil.asLocalDate() for consistency
DataBindingConstants.groovy Minor code style improvements (removed unnecessary class name prefix)
ProductService.groovy Fixed product list date filters to work with Instant fields and corrected field name from dateFormatter to dateFormatterManager

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +136 to +137
LocalDate localDate = asLocalDate(date)
return asInstant(localDate, defaultZone)
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in asInstant(String, ZoneId) has a potential bug. If the initial parse fails with a DateTimeException and defaultZone is provided, the code attempts to parse the string as a LocalDate at line 136. However, if this second parse also fails (e.g., for an invalid date string like "2000-01-32"), the exception from asLocalDate() will be thrown without context about the original error.

Consider wrapping the fallback parsing in a try-catch block and providing a more informative error message that indicates both parsing attempts failed:

try {
    LocalDate localDate = asLocalDate(date)
    return asInstant(localDate, defaultZone)
} catch (DateTimeException e2) {
    // If both parsing attempts failed, throw the original exception with additional context
    throw new DateTimeException("Failed to parse '${date}' as either a full datetime or a date-only string", e)
}
Suggested change
LocalDate localDate = asLocalDate(date)
return asInstant(localDate, defaultZone)
try {
LocalDate localDate = asLocalDate(date)
return asInstant(localDate, defaultZone)
} catch (DateTimeException e2) {
throw new DateTimeException("Failed to parse '${date}' as either a full datetime or a date-only string", e)
}

Copilot uses AI. Check for mistakes.
Date dateCreatedAfter = params.createdAfter ? Date.parse("MM/dd/yyyy", params.createdAfter) : null
Date dateCreatedBefore = params.createdBefore ? Date.parse("MM/dd/yyyy", params.createdBefore) : null

// TODO: The date picker on the React size only sends up date information, but Instants need a time and zone.
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment contains a typo: "React size" should be "React side".

Corrected text: "TODO: The date picker on the React side only sends up date information..."

Suggested change
// TODO: The date picker on the React size only sends up date information, but Instants need a time and zone.
// TODO: The date picker on the React side only sends up date information, but Instants need a time and zone.

Copilot uses AI. Check for mistakes.
* Parse a given String into a LocalDate of the given format.
*
* Because LocalDate is time and timezone agnostic, the String only expects day, month, and year elements. To avoid
* any timezone related confusion, Any additional data provided (such as time and timezone) will trigger an error.
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment has a capitalization error. "Any additional data provided" should not start with a capital 'A' after the comma in the previous sentence.

The sentence should read: "Because LocalDate is time and timezone agnostic, the String only expects day, month, and year elements. To avoid any timezone related confusion, any additional data provided (such as time and timezone) will trigger an error."

Suggested change
* any timezone related confusion, Any additional data provided (such as time and timezone) will trigger an error.
* any timezone related confusion, any additional data provided (such as time and timezone) will trigger an error.

Copilot uses AI. Check for mistakes.
* @param zone "Z", or "UTC" or "+01:00" or "America/Anchorage" for example.
*/
static Instant asInstant(LocalDate localDate, ZoneId zone) {
return localDate ? asInstant(asZonedDateTime(localDate, zone)) : null
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The asInstant(LocalDate, ZoneId) method is missing null safety for the zone parameter. Unlike other methods in this class that have default zone handling (e.g., asZonedDateTime(Instant, ZoneId) which defaults to system zone), this method will throw a NullPointerException if zone is null and localDate is not null.

Consider adding a null check or default value for the zone parameter:

static Instant asInstant(LocalDate localDate, ZoneId zone) {
    if (!zone) {
        throw new IllegalArgumentException("Zone cannot be null when converting LocalDate to Instant")
    }
    return localDate ? asInstant(asZonedDateTime(localDate, zone)) : null
}

Alternatively, provide a default zone like other methods in this class do.

Suggested change
return localDate ? asInstant(asZonedDateTime(localDate, zone)) : null
ZoneId zoneToUse = zone ?: getSystemZoneId()
return localDate ? asInstant(asZonedDateTime(localDate, zoneToUse)) : null

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +192
static ZonedDateTime asZonedDateTime(LocalDate localDate, ZoneId zone) {
return localDate ? localDate.atStartOfDay(zone) : null
}
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to asInstant(LocalDate, ZoneId), the asZonedDateTime(LocalDate, ZoneId) method lacks null safety for the zone parameter. If zone is null and localDate is not null, calling localDate.atStartOfDay(zone) will throw a NullPointerException.

Consider adding the same protection as suggested for asInstant(LocalDate, ZoneId) or provide a default zone value like zone=null with null-coalescing logic.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: backend Changes or discussions relating to the backend server type: bug Addresses unintended behaviours of the app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants