Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Localized trip times in notifications #201

Merged
merged 9 commits into from
Dec 20, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.ZonedDateTime;
import java.util.Date;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

/**
Expand Down Expand Up @@ -64,7 +64,8 @@ public static TripMonitorNotification createAlertNotification(
public static TripMonitorNotification createDelayNotification(
long delayInMinutes,
Date targetDatetime,
NotificationType delayType
NotificationType delayType,
Locale locale
) {
if (delayType != NotificationType.ARRIVAL_DELAY && delayType != NotificationType.DEPARTURE_DELAY) {
LOG.error("Delay notification not permitted for type {}", delayType);
Expand All @@ -85,9 +86,7 @@ public static TripMonitorNotification createDelayNotification(
"Your trip is now predicted to %s %s (at %s).",
delayType == NotificationType.ARRIVAL_DELAY ? "arrive" : "depart",
delayHumanTime,
ZonedDateTime
.ofInstant(targetDatetime.toInstant(), DateTimeUtils.getOtpZoneId())
.format(DateTimeUtils.NOTIFICATION_TIME_FORMATTER)
DateTimeUtils.formatShortDate(targetDatetime, locale)
)
);
}
Expand All @@ -110,12 +109,14 @@ public static TripMonitorNotification createItineraryNotFoundNotification(
* Creates an initial reminder of the itinerary monitoring.
*/
public static TripMonitorNotification createInitialReminderNotification(
MonitoredTrip trip
MonitoredTrip trip, Locale locale
) {
// TODO: i18n.
return new TripMonitorNotification(
NotificationType.INITIAL_REMINDER,
String.format("Reminder for %s at %s.", trip.tripName, trip.tripTime)
String.format("Reminder for %s at %s.",
trip.tripName,
DateTimeUtils.formatShortDate(trip.itinerary.startTime, locale)
)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import java.util.Comparator;
import java.util.Date;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -86,6 +87,12 @@ public class CheckMonitoredTrip implements Runnable {
*/
ZonedDateTime targetZonedDateTime;

/** Caches the user associated with a trip */
private OtpUser cachedUser;

/** Whether an attempt has been made to retrieve the user. */
private boolean userChecked;

public CheckMonitoredTrip(MonitoredTrip trip) throws CloneNotSupportedException {
this.trip = trip;
previousJourneyState = trip.journeyState;
Expand Down Expand Up @@ -142,7 +149,7 @@ private void addInitialReminderIfNeeded() {

if (!trip.isInactive() && isFirstTimeCheckWithinLeadMonitoringTime && userWantsInitialReminder) {
enqueueNotification(
TripMonitorNotification.createInitialReminderNotification(trip)
TripMonitorNotification.createInitialReminderNotification(trip, getOtpUserLocale())
);
}
}
Expand Down Expand Up @@ -393,7 +400,8 @@ public TripMonitorNotification checkTripForDelay(NotificationType delayType) {
return TripMonitorNotification.createDelayNotification(
delayMinutes,
matchingItineraryTargetTime,
delayType
delayType,
getOtpUserLocale()
);
}
return null;
Expand All @@ -404,7 +412,7 @@ public TripMonitorNotification checkTripForDelay(NotificationType delayType) {
* preferences.
*/
private void sendNotifications() {
OtpUser otpUser = Persistence.otpUsers.getById(trip.userId);
OtpUser otpUser = getOtpUser();
if (otpUser == null) {
LOG.error("Cannot find user for id {}", trip.userId);
// TODO: Bugsnag / delete monitored trip?
Expand Down Expand Up @@ -769,4 +777,23 @@ private boolean updateMonitoredTrip() {
Persistence.monitoredTrips.replace(trip.id, trip);
return true;
}

/**
* Retrieves and caches the user on first call (assuming the user for a trip does not change during a trip check).
*/
private OtpUser getOtpUser() {
if (!userChecked) {
cachedUser = Persistence.otpUsers.getById(trip.userId);
userChecked = true;
}
return cachedUser;
}

/**
* Retrieves and caches the user on first call (assuming the user for a trip does not change).
*/
private Locale getOtpUserLocale() {
OtpUser user = getOtpUser();
return Locale.forLanguageTag(user == null || user.preferredLocale == null ? "en-US" : user.preferredLocale);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.FormatStyle;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand All @@ -41,9 +43,6 @@ private DateTimeUtils() {
public static final DateTimeFormatter DEFAULT_DATE_FORMATTER = DateTimeFormatter.ofPattern(
DEFAULT_DATE_FORMAT_PATTERN
);
public static final DateTimeFormatter NOTIFICATION_TIME_FORMATTER = DateTimeFormatter.ofPattern(
ConfigUtils.getConfigPropertyAsText("NOTIFICATION_TIME_FORMAT", "HH:mm")
Copy link
Contributor

Choose a reason for hiding this comment

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

Can "NOTIFICATION_TIME_FORMAT" now be removed from env.yml.tmp, env.schema.json and README.md? It doesn't appear to be used anywhere else.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed in 8811f9f, thanks for reminding me of these places!

);

/**
* These are internal variables that can be used to mock dates and times in tests
Expand Down Expand Up @@ -97,6 +96,17 @@ public static String getStringFromDate(LocalDateTime localDate, String expectedD
return localDate.format(expectedDateFormat);
}

/**
* Helper to format a date in short format (e.g. "5:40 PM" - no seconds) in the specified locale.
*/
public static String formatShortDate(Date date, Locale locale) {
return DateTimeFormatter
.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(locale)
.withZone(DateTimeUtils.getOtpZoneId())
.format(date.toInstant());
}

public static Date nowAsDate() {
return new Date(currentTimeMillis());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import org.junit.jupiter.api.Test;
import org.opentripplanner.middleware.otp.response.Itinerary;
import org.opentripplanner.middleware.tripmonitor.jobs.NotificationType;
import org.opentripplanner.middleware.utils.DateTimeUtils;

import java.time.ZonedDateTime;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;

class TripMonitorNotificationTest {
public static final Locale EN_US_LOCALE = Locale.forLanguageTag("en-US");

@Test
void testSortOrderPutsInitialReminderFirst() {
TripMonitorNotification reminder = new TripMonitorNotification(NotificationType.INITIAL_REMINDER, "reminder");
Expand All @@ -31,4 +39,41 @@ void testSortOrderPutsInitialReminderFirst() {
assertNotEquals(reminder, sortedNotifications.get(i));
}
}

@Test
void canCreateInitialReminder() {
MonitoredTrip trip = makeSampleTrip();
TripMonitorNotification notification = TripMonitorNotification.createInitialReminderNotification(trip, EN_US_LOCALE);
assertEquals("Reminder for Sample Trip at 5:44 PM.", notification.body);
}

@Test
void canCreateDelayedTripNotification() {
MonitoredTrip trip = makeSampleTrip();
TripMonitorNotification notification = TripMonitorNotification.createDelayNotification(
10,
trip.itinerary.startTime,
NotificationType.ARRIVAL_DELAY,
EN_US_LOCALE
);
assertNotNull(notification);
assertEquals("Your trip is now predicted to arrive 10 minutes late (at 5:44 PM).", notification.body);
}

private static MonitoredTrip makeSampleTrip() {
MonitoredTrip trip = new MonitoredTrip();
trip.tripName = "Sample Trip";

// tripTime is for the OTP query, is included for reference only, and should not be used for notifications.
trip.tripTime = "13:31";

// Set a start time for the itinerary, in the ambient/default OTP zone.
ZonedDateTime startTime = ZonedDateTime.of(2023, 2, 12, 17, 44, 0, 0, DateTimeUtils.getOtpZoneId());

Itinerary itinerary = new Itinerary();
itinerary.startTime = Date.from(startTime.toInstant());

trip.itinerary = itinerary;
return trip;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,13 @@ private static List<DelayNotificationTestCase> createDelayNotificationTestCases
testCases.add(new DelayNotificationTestCase(
twentyMinutesLateTimeTrip,
NotificationType.DEPARTURE_DELAY,
"Your trip is now predicted to depart 20 minutes late (at 09:00).",
"Your trip is now predicted to depart 20 minutes late (at 9:00 AM).",
"should create a departure notification for 20 minute late trip"
));
testCases.add(new DelayNotificationTestCase(
twentyMinutesLateTimeTrip,
NotificationType.ARRIVAL_DELAY,
"Your trip is now predicted to arrive 20 minutes late (at 09:18).",
"Your trip is now predicted to arrive 20 minutes late (at 9:18 AM).",
"should create a arrival notification for 20 minute late trip"
));

Expand Down Expand Up @@ -211,13 +211,13 @@ private static List<DelayNotificationTestCase> createDelayNotificationTestCases
testCases.add(new DelayNotificationTestCase(
onTimeTripWithUpdatedThreshold,
NotificationType.DEPARTURE_DELAY,
"Your trip is now predicted to depart about on time (at 08:40).",
"Your trip is now predicted to depart about on time (at 8:40 AM).",
"should create a departure notification for on-time trip w/ 20 minute late threshold and 18 minute late baseline"
));
testCases.add(new DelayNotificationTestCase(
onTimeTripWithUpdatedThreshold,
NotificationType.ARRIVAL_DELAY,
"Your trip is now predicted to arrive about on time (at 08:58).",
"Your trip is now predicted to arrive about on time (at 8:58 AM).",
"should create a arrival notification for on-time trip w/ 20 minute late threshold and 18 minute late baseline"
));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.opentripplanner.middleware.utils;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Locale;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;

class DateTimeUtilsTest {
@ParameterizedTest
@MethodSource("createDateFormatCases")
void supportsDateFormatsInSeveralLocales(String localeTag, String result) {
ZonedDateTime zonedTime = ZonedDateTime.of(2023, 2, 12, 17, 44, 0, 0, DateTimeUtils.getOtpZoneId());
assertEquals(result, DateTimeUtils.formatShortDate(
Date.from(zonedTime.toInstant()),
Locale.forLanguageTag(localeTag))
);
}

private static Stream<Arguments> createDateFormatCases() {
return Stream.of(
Arguments.of("en-US", "5:44 PM"),
Arguments.of("fr", "17:44"),
Arguments.of("es", "17:44"),
Arguments.of("ko", "오후 5:44"),
Arguments.of("vi", "17:44"),
Arguments.of("zh", "下午5:44"), // Note: The Format.JS library shows 24-hour format for Chinese.
Arguments.of("ru", "17:44"),
Arguments.of("tl", "5:44 PM")
);
}
}
Loading