diff --git a/app/src/main/java/org/transitclock/api/reports/PredictionAccuracyQuery.java b/app/src/main/java/org/transitclock/api/reports/PredictionAccuracyQuery.java index 285c3a8f6..c0285ad3e 100644 --- a/app/src/main/java/org/transitclock/api/reports/PredictionAccuracyQuery.java +++ b/app/src/main/java/org/transitclock/api/reports/PredictionAccuracyQuery.java @@ -1,20 +1,27 @@ /* (C)2023 */ package org.transitclock.api.reports; -import lombok.extern.slf4j.Slf4j; -import org.transitclock.domain.GenericQuery; -import org.transitclock.utils.Time; - import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Time; import java.sql.Timestamp; +import java.text.DateFormat; import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.transitclock.domain.GenericQuery; + +import com.google.common.base.Strings; +import lombok.extern.slf4j.Slf4j; + +import static org.transitclock.utils.Time.parseDate; + /** * For doing SQL query and generating JSON data for a prediction accuracy chart. This abstract class * does the SQL query and puts data into a map. Then a subclass must be used to convert the data to @@ -56,8 +63,9 @@ public enum IntervalsType { * For converting from a string to an IntervalsType * * @param text String to be converted + * * @return The corresponding IntervalsType, or IntervalsType.PERCENTAGE as the default if - * text doesn't match a type. + * text doesn't match a type. */ public static IntervalsType createIntervalsType(String text) { for (IntervalsType type : IntervalsType.values()) { @@ -67,7 +75,9 @@ public static IntervalsType createIntervalsType(String text) { } // If a bad non-null value was specified then log the error - if (text != null) logger.error("\"{}\" is not a valid IntervalsType", text); + if (text != null) { + logger.error("\"{}\" is not a valid IntervalsType", text); + } // Couldn't match so use default value return IntervalsType.PERCENTAGE; @@ -79,7 +89,6 @@ public String toString() { } } - public PredictionAccuracyQuery(String agencyId) throws SQLException { super(agencyId); } @@ -91,6 +100,7 @@ public PredictionAccuracyQuery(String agencyId) throws SQLException { * is in the middle of the range. * * @param predLength + * * @return */ private static int index(int predLength) { @@ -111,7 +121,9 @@ private void addDataToMap(int predLength, int predAccuracy, String source) { // Determine the index of the appropriate prediction bucket int predictionBucketIndex = index(predLength); - while (predictionBuckets.size() < predictionBucketIndex + 1) predictionBuckets.add(new ArrayList<>()); + while (predictionBuckets.size() < predictionBucketIndex + 1) { + predictionBuckets.add(new ArrayList<>()); + } if (predictionBucketIndex < predictionBuckets.size() && predictionBucketIndex >= 0) { List predictionAccuracies = predictionBuckets.get(predictionBucketIndex); // Add the prediction accuracy to the bucket. @@ -131,23 +143,25 @@ private void addDataToMap(int predLength, int predAccuracy, String source) { * Performs the SQL query and puts the resulting data into the map. * * @param beginDateStr Begin date for date range of data to use. - * @param numDaysStr How many days to do the query for + * @param numDaysStr How many days to do the query for * @param beginTimeStr For specifying time of day between the begin and end date to use data - * for. Can thereby specify a date range of a week but then just look at data for particular - * time of day, such as 7am to 9am, for those days. Set to null or empty string to use data - * for entire day. - * @param endTimeStr For specifying time of day between the begin and end date to use data for. - * Can thereby specify a date range of a week but then just look at data for particular time - * of day, such as 7am to 9am, for those days. Set to null or empty string to use data for - * entire day. - * @param routeIds Array of IDs of routes to get data for - * @param predSource The source of the predictions. Can be null or "" (for all), "Transitime", - * or "Other" - * @param predType Whether predictions are affected by wait stop. Can be "" (for all), - * "AffectedByWaitStop", or "NotAffectedByWaitStop". + * for. Can thereby specify a date range of a week but then just look at data for particular + * time of day, such as 7am to 9am, for those days. Set to null or empty string to use data + * for entire day. + * @param endTimeStr For specifying time of day between the begin and end date to use data for. + * Can thereby specify a date range of a week but then just look at data for particular time + * of day, such as 7am to 9am, for those days. Set to null or empty string to use data for + * entire day. + * @param routeIds Array of IDs of routes to get data for + * @param predSource The source of the predictions. Can be null or "" (for all), "Transitime", + * or "Other" + * @param predType Whether predictions are affected by wait stop. Can be "" (for all), + * "AffectedByWaitStop", or "NotAffectedByWaitStop". + * * @throws SQLException * @throws ParseException */ + protected void doQuery( String beginDateStr, String numDaysStr, @@ -164,28 +178,50 @@ protected void doQuery( throw new ParseException( "Begin date to end date spans more than a month for endDate=" + " startDate=" - + Time.parseDate(beginDateStr) + + parseDate(beginDateStr) + " Number of days of " + numDays + " spans more than a month", 0); } - String timeSql = ""; - String mySqlTimeSql = ""; - if ((beginTimeStr != null && !beginTimeStr.isEmpty()) || (endTimeStr != null && !endTimeStr.isEmpty())) { - // If only begin or only end time set then use default value - if (beginTimeStr == null || beginTimeStr.isEmpty()) beginTimeStr = "00:00:00"; - else { - // beginTimeStr set so make sure it is valid, and prevent - // possible SQL injection - if (!beginTimeStr.matches("\\d+:\\d+")) - throw new ParseException("begin time \"" + beginTimeStr + "\" is not valid.", 0); + Date beginDate; + try { + DateFormat defaultDateFormat = new SimpleDateFormat("MM-dd-yyyy"); + DateFormat altDateFormat = new SimpleDateFormat("yyyy-MM-dd"); + beginDate = (beginDateStr.charAt(4) != '-') ? defaultDateFormat.parse(beginDateStr) : altDateFormat.parse(beginDateStr); + } catch (ParseException ex) { + logger.warn("Invalid date format. Use MM-dd-yyyy or yyyy-MM-dd. " + ex.getMessage()); + throw ex; + } + + // Parse beginTime and endTime + Time beginTime; + Time endTime; + SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss"); + SimpleDateFormat shortTimeFormat = new SimpleDateFormat("HH:mm"); + + if (Strings.isNullOrEmpty(beginTimeStr) || Strings.isNullOrEmpty(endTimeStr)) { + if (Strings.isNullOrEmpty(beginTimeStr)) { + beginTimeStr = "00:00:00"; + } + if (Strings.isNullOrEmpty(endTimeStr)) { + endTimeStr = "23:59:59"; } - if (endTimeStr == null || endTimeStr.isEmpty()) endTimeStr = "23:59:59"; - // time param is jdbc param -- no need to check for injection attacks - timeSql = " AND arrival_departure_time::time BETWEEN ? AND ? "; } + try { + beginTime = (beginTimeStr.length() < 6) + ? new Time(shortTimeFormat.parse(beginTimeStr).getTime()) + : new Time(timeFormat.parse(beginTimeStr).getTime()); + endTime = (endTimeStr.length() < 6) + ? new Time(shortTimeFormat.parse(endTimeStr).getTime()) + : new Time(timeFormat.parse(endTimeStr).getTime()); + } catch (ParseException e) { + logger.warn("Invalid time format: " + e.getMessage()); + throw e; + } + String timeSql = " AND arrival_departure_time::time BETWEEN ? AND ? "; + // Determine route portion of SQL // Need to examine each route ID twice since doing a // routeId='stableId' OR routeShortName='stableId' in @@ -200,78 +236,41 @@ protected void doQuery( routeSql += ")"; } - // Determine the source portion of the SQL. Default is to provide - // predictions for all sources - String sourceSql = ""; - if (predSource != null && !predSource.isEmpty()) { - if (predSource.equals("Transitime")) { - // Only "Transitime" predictions - sourceSql = " AND prediction_source='Transitime'"; + // TODO generate database independent SQL if possible! + + // Put the entire SQL query together + StringBuilder sql = new StringBuilder("SELECT to_char(predicted_time-prediction_read_time, 'SSSS')::integer as predLength, ") + .append("prediction_accuracy_msecs/1000 as predAccuracy, ") + .append(" prediction_source as source FROM prediction_accuracy WHERE") + .append(" arrival_departure_time BETWEEN ? AND TIMESTAMP '") + .append(beginDate).append("' + INTERVAL '") + .append(numDays).append(" day' ").append(timeSql) + .append(" AND predicted_time - prediction_read_time < '00:15:00' ") + .append(routeSql); + + + // Add prediction type condition if provided + if (!Strings.isNullOrEmpty(predType)) { + if (predType.equals("AffectedByWaitStop")) { + sql.append("AND affected_by_wait_stop = true "); } else { - // Anything but "Transitime" - sourceSql = " AND prediction_source<>'Transitime'"; + sql.append("AND affected_by_wait_stop = false "); } } - // Determine SQL for prediction type. Can be "" (for - // all), "AffectedByWaitStop", or "NotAffectedByWaitStop". - String predTypeSql = ""; - if (predType != null && !predType.isEmpty()) { - if (predSource.equals("AffectedByWaitStop")) { - // Only "AffectedByLayover" predictions - predTypeSql = " AND affected_by_wait_stop = true "; + // Add prediction source condition if provided + if (!Strings.isNullOrEmpty(predSource)) { + if (predSource.equals("Transitime")) { + sql.append("AND prediction_source = 'Transitime' "); } else { - // Only "NotAffectedByLayover" predictions - predTypeSql = " AND affected_by_wait_stop = false "; + sql.append("AND prediction_source <> 'Transitime' "); } } - // TODO generate database independent SQL if possible! - // Put the entire SQL query together - String sql = "SELECT to_char(predicted_time-prediction_read_time, 'SSSS')::integer as predLength, " - + "prediction_accuracy_msecs/1000 as predAccuracy, " - + " prediction_source as source FROM prediction_accuracy WHERE" - + " arrival_departure_time BETWEEN ? AND TIMESTAMP '" + beginDateStr - + "' + INTERVAL '" - + numDays - + " day' " - + timeSql - + " AND predicted_time - prediction_read_time < '00:15:00' " - + routeSql - + sourceSql - + predTypeSql; - - PreparedStatement statement = null; try { logger.debug("SQL: {}", sql); - statement = getConnection().prepareStatement(sql); - - // Determine the date parameters for the query - Timestamp beginDate = null; - java.util.Date date = Time.parse(beginDateStr); - beginDate = new Timestamp(date.getTime()); - - // Determine the time parameters for the query - // If begin time not set but end time is then use midnight as begin - // time - if ((beginTimeStr == null || beginTimeStr.isEmpty()) && endTimeStr != null && !endTimeStr.isEmpty()) { - beginTimeStr = "00:00:00"; - } - // If end time not set but begin time is then use midnight as end - // time - if ((endTimeStr == null || endTimeStr.isEmpty()) && beginTimeStr != null && !beginTimeStr.isEmpty()) { - endTimeStr = "23:59:59"; - } - - java.sql.Time beginTime = null; - java.sql.Time endTime = null; - if (beginTimeStr != null && !beginTimeStr.isEmpty()) { - beginTime = new java.sql.Time(Time.parseTimeOfDay(beginTimeStr) * Time.MS_PER_SEC); - } - if (endTimeStr != null && !endTimeStr.isEmpty()) { - endTime = new java.sql.Time(Time.parseTimeOfDay(endTimeStr) * Time.MS_PER_SEC); - } + statement = getConnection().prepareStatement(sql.toString()); logger.debug( "beginDate {} beginDateStr {} endDateStr {} beginTime {} beginTimeStr {}" @@ -286,16 +285,12 @@ protected void doQuery( // Set the parameters for the query int i = 1; - statement.setTimestamp(i++, beginDate); + statement.setTimestamp(i++, new Timestamp(beginDate.getTime())); + statement.setTime(i++, beginTime); + statement.setTime(i++, endTime); - if (beginTime != null) { - statement.setTime(i++, beginTime); - } - if (endTime != null) { - statement.setTime(i++, endTime); - } if (routeIds != null) { - for (String routeId : routeIds) + for (String routeId : routeIds) { if (!routeId.trim().isEmpty()) { // Need to add the route ID twice since doing a // routeId='stableId' OR routeShortName='stableId' in @@ -304,6 +299,7 @@ protected void doQuery( statement.setString(i++, routeId); statement.setString(i++, routeId); } + } } // Actually execute the query @@ -320,11 +316,12 @@ protected void doQuery( } rs.close(); - } catch (SQLException e) { - throw e; + } catch (SQLException ex) { + throw ex; } finally { - if (statement != null) + if (statement != null) { statement.close(); + } if (!getConnection().isClosed()) { getConnection().close(); } diff --git a/app/src/main/java/org/transitclock/api/reports/ScheduleAdherenceController.java b/app/src/main/java/org/transitclock/api/reports/ScheduleAdherenceController.java index d88fc919f..bb3a95cc9 100644 --- a/app/src/main/java/org/transitclock/api/reports/ScheduleAdherenceController.java +++ b/app/src/main/java/org/transitclock/api/reports/ScheduleAdherenceController.java @@ -1,46 +1,50 @@ /* (C)2023 */ package org.transitclock.api.reports; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.transitclock.config.BooleanConfigValue; -import org.transitclock.config.IntegerConfigValue; - -import java.util.*; - +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.transitclock.domain.hibernate.HibernateUtils; +import org.transitclock.domain.structs.ArrivalDeparture; +import org.transitclock.properties.WebProperties; +import org.transitclock.utils.Time; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.Session; +import org.hibernate.query.Query; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor public class ScheduleAdherenceController { - private static final Logger logger = LoggerFactory.getLogger(ScheduleAdherenceController.class); // TODO: Combine routeScheduleAdherence and stopScheduleAdherence // - Make this a REST endpoint // problem - negative schedule adherence means we're late - private static IntegerConfigValue scheduleEarlySeconds = - new IntegerConfigValue("transitclock.web.scheduleEarlyMinutes", -120, "Schedule Adherence early limit"); - - public static int getScheduleEarlySeconds() { - return scheduleEarlySeconds.getValue(); - } - - private static IntegerConfigValue scheduleLateSeconds = - new IntegerConfigValue("transitclock.web.scheduleLateMinutes", 420, "Schedule Adherence late limit"); - - public static int getScheduleLateSeconds() { - return scheduleLateSeconds.getValue(); - } - - private static BooleanConfigValue usePredictionLimits = new BooleanConfigValue( - "transitme.web.userPredictionLimits", - Boolean.FALSE, - "use the allowable early/late report params or use configured schedule limits"); - + private final WebProperties webProperties; // private static final String ADHERENCE_SQL = "(time - scheduledTime) AS scheduleAdherence"; // private static final Projection ADHERENCE_PROJECTION = Projections.sqlProjection( // ADHERENCE_SQL, new String[] {"scheduleAdherence"}, new Type[] {DoubleType.INSTANCE}); // private static final Projection AVG_ADHERENCE_PROJECTION = Projections.sqlProjection( // "avg" + ADHERENCE_SQL, new String[] {"scheduleAdherence"}, new Type[] {DoubleType.INSTANCE}); - public static List stopScheduleAdherence( + public List stopScheduleAdherence( Date startDate, int numDays, String startTime, @@ -52,7 +56,7 @@ public static List stopScheduleAdherence( return groupScheduleAdherence(startDate, numDays, startTime, endTime, "stopId", stopIds, byStop, datatype); } - public static List routeScheduleAdherence( + public List routeScheduleAdherence( Date startDate, int numDays, String startTime, @@ -64,128 +68,141 @@ public static List routeScheduleAdherence( return groupScheduleAdherence(startDate, numDays, startTime, endTime, "routeId", routeIds, byRoute, datatype); } - public static List routeScheduleAdherenceSummary( - Date startDate, - int numDays, - String startTime, - String endTime, - Double earlyLimitParam, - Double lateLimitParam, - List routeIds) { + public Map routeScheduleAdherenceSummary(Date startDate, int numDays, String startTime, String endTime, Double earlyLimitParam, Double lateLimitParam, List routeIds) { int count = 0; int early = 0; int late = 0; int ontime = 0; - Double earlyLimit = - (usePredictionLimits.getValue() ? earlyLimitParam : (double) scheduleEarlySeconds.getValue()); - Double lateLimit = (usePredictionLimits.getValue() ? lateLimitParam : (double) scheduleLateSeconds.getValue()); + + var earlyLimit = (webProperties.getUsePredictionLimits() ? earlyLimitParam : (double) webProperties.getScheduleEarlyMinutes()); + var lateLimit = (webProperties.getUsePredictionLimits() ? lateLimitParam : (double) webProperties.getScheduleLateMinutes()); + List results = routeScheduleAdherence(startDate, numDays, startTime, endTime, routeIds, false, null); + Map result = new HashMap<>(); for (Object o : results) { count++; - HashMap hm = (HashMap) o; - Double d = (Double) hm.get("scheduleAdherence"); - if (d > lateLimit) { + + var hm = (HashMap) o; + Duration d = (Duration) hm.get("scheduleAdherence"); + double totalSeconds = d.toMillis() / 1000.0; + + if (totalSeconds > lateLimit) { late++; - } else if (d < earlyLimit) { + } else if (totalSeconds < earlyLimit) { early++; } else { ontime++; } } - logger.info( - "query complete -- earlyLimit={}, lateLimit={}, early={}, ontime={}, late={}," + " count={}", - earlyLimit, - lateLimit, - early, - ontime, - late, - count); + logger.info( "query complete -- earlyLimit={}, lateLimit={}, early={}, onTime={}, late={}," + " count={}", + earlyLimit, + lateLimit, + early, + ontime, + late, + count); + double earlyPercent = (1.0 - (double) (count - early) / count) * 100; double onTimePercent = (1.0 - (double) (count - ontime) / count) * 100; double latePercent = (1.0 - (double) (count - late) / count) * 100; - logger.info( - "count={} earlyPercent={} onTimePercent={} latePercent={}", - count, - earlyPercent, - onTimePercent, - latePercent); - Integer[] summary = new Integer[] {count, (int) earlyPercent, (int) onTimePercent, (int) latePercent}; - return Arrays.asList(summary); + logger.info( "count=static{} earlyPercent={} onTimePercent={} latePercent={}", + count, + earlyPercent, + onTimePercent, + latePercent); + DecimalFormat df = new DecimalFormat("#.##"); + df.setRoundingMode(RoundingMode.DOWN); + + result.put("count", String.valueOf(count)); + result.put("early", df.format(earlyPercent)); + result.put("late", df.format(latePercent)); + result.put("onTime", df.format(onTimePercent)); + return result; } - private static List groupScheduleAdherence( - Date startDate, - int numDays, - String startTime, - String endTime, - String groupName, - List idsOrEmpty, - boolean byGroup, - String datatype) { -/* + private List groupScheduleAdherence(Date startDate, int numDays, String startTime, String endTime, String groupName, List idsOrEmpty, boolean byGroup, String datatype) { - var qentity = QArrivalDeparture.arrivalDeparture; - Session session = HibernateUtils.getSession(); - JPAQuery query = new JPAQuery<>(session); - - // filter ids which may be empty. List ids = new ArrayList<>(); - if (idsOrEmpty != null) - for (String id : idsOrEmpty) + if (idsOrEmpty != null) { + for (String id : idsOrEmpty) { if (!StringUtils.isBlank(id)) { ids.add(id); } + } + } + // Calculate end date based on start date and numDays Date endDate = new Date(startDate.getTime() + (numDays * Time.MS_PER_DAY)); - ProjectionList proj = Projections.projectionList(); - - if (byGroup) - proj.add(Projections.groupProperty(groupName), groupName) - .add(Projections.rowCount(), "count"); - else - proj.add(Projections.property("routeId"), "routeId") - .add(Projections.property("stopId"), "stopId") - .add(Projections.property("tripId"), "tripId"); - - proj.add(byGroup ? AVG_ADHERENCE_PROJECTION : ADHERENCE_PROJECTION, "scheduleAdherence"); - - DetachedCriteria criteria = DetachedCriteria.forClass(ArrivalDeparture.class) - .add(Restrictions.between("time", startDate, endDate)) - .add(Restrictions.isNotNull("scheduledTime")); + try(Session session = HibernateUtils.getSession()) { + // Create Query + CriteriaBuilder cb = session.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(Object[].class); + Root root = query.from(ArrivalDeparture.class); + + // Building predicates + List predicates = new ArrayList<>(); + predicates.add(cb.between(root.get("time"), startDate, endDate)); + predicates.add(cb.isNotNull(root.get("scheduledTime"))); + + // Check if we're dealing with 'arrival' or 'departure' + if ("arrival".equals(datatype)) { + predicates.add(cb.isTrue(root.get("isArrival"))); + } else if ("departure".equals(datatype)) { + predicates.add(cb.isFalse(root.get("isArrival"))); + } - if ("arrival".equals(datatype)) criteria.add(Restrictions.eq("isArrival", true)); - else if ("departure".equals(datatype)) criteria.add(Restrictions.eq("isArrival", false)); + Expression timePartExpr = cb.function("TO_CHAR", String.class, root.get("time"), cb.literal("HH24:MI:SS")); + predicates.add(cb.greaterThanOrEqualTo(timePartExpr, startTime)); + predicates.add(cb.lessThanOrEqualTo(timePartExpr, endTime)); - String sql = "time({alias}.time) between ? and ?"; - String[] values = {startTime, endTime}; - Type[] types = {StringType.INSTANCE, StringType.INSTANCE}; - criteria.add(Restrictions.sqlRestriction(sql, values, types)); + if (!ids.isEmpty()) { + predicates.add(root.get(groupName).in(ids)); + } - criteria.setProjection(proj).setResultTransformer(DetachedCriteria.ALIAS_TO_ENTITY_MAP); + query.where(predicates.toArray(new Predicate[0])); - if (ids != null && ids.size() > 0) criteria.add(Restrictions.in(groupName, ids)); -*/ - return Collections.emptyList(); - } + // Grouping logic based on byGroup flag + if (byGroup) { + query.multiselect( + root.get(groupName), + cb.count(root), + cb.avg(cb.diff(root.get("time"), root.get("scheduledTime"))) + ).groupBy(root.get(groupName)); + } else { + query.multiselect( + root.get("routeId"), + root.get("stopId"), + root.get("tripId"), + cb.diff(root.get("time"), root.get("scheduledTime")) + ); + } - private static Date endOfDay(Date endDate) { - Calendar c = Calendar.getInstance(); - c.setTime(endDate); - c.set(Calendar.HOUR, 23); - c.set(Calendar.MINUTE, 59); - c.set(Calendar.SECOND, 59); - return c.getTime(); + Query hibernateQuery = session.createQuery(query); + List results = hibernateQuery.getResultList(); + // Get result + return results.stream() + .map(result -> { + HashMap map = new HashMap<>(); + if (byGroup) { + map.put(groupName, result[0]); + map.put("count", result[1]); + map.put("scheduleAdherence", result[2]); + } else { + map.put("routeId", result[0]); + map.put("stopId", result[1]); + map.put("tripId", result[2]); + map.put("scheduleAdherence", result[3]); + } + return map; + }) + .collect(Collectors.toList()); + + } catch (Exception esqEx) { + esqEx.printStackTrace(); + } + return List.of(); } -// -// private static List dbify(DetachedCriteria criteria) { -// Session session = HibernateUtils.getSession(); -// try { -// return criteria.getExecutableCriteria(session).list(); -// } finally { -// session.close(); -// } -// } } diff --git a/app/src/main/java/org/transitclock/api/resources/ReportsApi.java b/app/src/main/java/org/transitclock/api/resources/ReportsApi.java index 63d24dd78..4978b8fc9 100644 --- a/app/src/main/java/org/transitclock/api/resources/ReportsApi.java +++ b/app/src/main/java/org/transitclock/api/resources/ReportsApi.java @@ -4,6 +4,7 @@ import java.sql.SQLException; import java.text.ParseException; import java.util.List; +import java.util.Map; import org.transitclock.api.utils.StandardParameters; @@ -42,11 +43,12 @@ ResponseEntity getTripsWithTravelTimes( summary = "Returns avl report.", description = "Returns avl report.", tags = {"report", "vehicle"}) + @GetMapping(value = "/reports/avlReport", produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE}) ResponseEntity getAvlReport( StandardParameters stdParameters, - @Parameter(description = "Vehicle id") @RequestParam(value = "v") String vehicleId, + @Parameter(description = "Vehicle id") @RequestParam(value = "v", required = false) String vehicleId, @Parameter(description = "Begin date(MM-DD-YYYY or YYYY-MM-DD") @RequestParam(value = "beginDate") String beginDate, @Parameter(description = "Num days.", required = false) @RequestParam(value = "numDays", defaultValue = "1", required = false) int numDays, @Parameter(description = "Begin time(HH:MM)", required = false) @RequestParam(value = "beginTime", required = false) String beginTime, @@ -121,7 +123,7 @@ ResponseEntity scheduleAdhReport( produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE}) ResponseEntity reportForStopById( StandardParameters stdParameters, - @Parameter(description = "Stop id") @RequestParam(value = "stopId") String stopId, + @Parameter(description = "Stop id") @RequestParam(value = "id") String stopId, @Parameter(description = "Begin date(MM-DD-YYYY or YYYY-MM-DD") @RequestParam(value = "beginDate") String beginDate, @Parameter(description = "Num days.") @RequestParam(value = "numDays", defaultValue = "1", required = false) int numDays, @Parameter(description = "Begin time(HH:MM)") @RequestParam(value = "beginTime", required = false) String beginTime, @@ -146,7 +148,7 @@ ResponseEntity reportForStopById( ResponseEntity predAccuracyRangeData(HttpServletRequest request) throws SQLException, ParseException; @GetMapping(value = "/reports/data/summaryScheduleAdherence.jsp") - ResponseEntity> summaryScheduleAdherence(HttpServletRequest request) throws ParseException; + ResponseEntity> summaryScheduleAdherence(HttpServletRequest request) throws ParseException; @GetMapping(value = "/reports/predAccuracyScatterData.jsp") ResponseEntity predAccuracyScatterData(HttpServletRequest request) throws ParseException, SQLException; diff --git a/app/src/main/java/org/transitclock/api/resources/ReportsResource.java b/app/src/main/java/org/transitclock/api/resources/ReportsResource.java index 5d35df852..65692f673 100644 --- a/app/src/main/java/org/transitclock/api/resources/ReportsResource.java +++ b/app/src/main/java/org/transitclock/api/resources/ReportsResource.java @@ -1,14 +1,20 @@ package org.transitclock.api.resources; +import com.google.common.base.Strings; + import jakarta.servlet.http.HttpServletRequest; import java.sql.SQLException; +import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Objects; +import org.springframework.beans.factory.annotation.Autowired; + import org.transitclock.api.reports.ChartGenericJsonQuery; import org.transitclock.api.reports.PredAccuracyIntervalQuery; import org.transitclock.api.reports.PredAccuracyRangeQuery; @@ -28,6 +34,9 @@ @RestController public class ReportsResource extends BaseApiResource implements ReportsApi { + @Autowired + ScheduleAdherenceController scheduleAdherenceController; + @Override public ResponseEntity getTripsWithTravelTimes( StandardParameters stdParameters, @@ -193,9 +202,8 @@ public ResponseEntity predAccuracyIntervalsData(HttpServletRequest reque } // Respond with the JSON string - ResponseEntity response = ResponseEntity.status(HttpStatus.OK) + return ResponseEntity.status(HttpStatus.OK) .body(jsonString); - return response; } @Override @@ -214,7 +222,7 @@ public ResponseEntity predAccuracyRangeData(HttpServletRequest request) String predictionType = request.getParameter("predictionType"); - int allowableEarlySec = (int) 1.5 * Time.SEC_PER_MIN; // Default value + int allowableEarlySec = Time.SEC_PER_MIN; // Default value String allowableEarlyStr = request.getParameter("allowableEarly"); try { if (allowableEarlyStr != null && !allowableEarlyStr.isEmpty()) { @@ -273,7 +281,7 @@ public ResponseEntity predAccuracyRangeData(HttpServletRequest request) } @Override - public ResponseEntity> summaryScheduleAdherence(HttpServletRequest request) throws ParseException { + public ResponseEntity> summaryScheduleAdherence(HttpServletRequest request) throws ParseException { String startDateStr = request.getParameter("beginDate"); String numDaysStr = request.getParameter("numDays"); String startTime = request.getParameter("beginTime"); @@ -283,43 +291,45 @@ public ResponseEntity> summaryScheduleAdherence(HttpServletRequest double earlyLimit = -60.0; double lateLimit = 60.0; - if (StringUtils.hasText(startTime)) { + if (!StringUtils.hasText(startTime)) { startTime = "00:00:00"; - } else { + } else if (startTime.length() < 6) { startTime += ":00"; } - if (StringUtils.hasText(endTime)) { + if (!StringUtils.hasText(endTime)) { endTime = "23:59:59"; - } else { + } else if (endTime.length() < 6) { endTime += ":00"; } - if (!StringUtils.hasText(earlyLimitStr)) { - earlyLimit = Double.parseDouble(earlyLimitStr) * 60; + if (StringUtils.hasText(earlyLimitStr)) { + earlyLimit = Double.parseDouble(earlyLimitStr) * -60; } - if (!StringUtils.hasText(lateLimitStr)) { + if (StringUtils.hasText(lateLimitStr)) { lateLimit = Double.parseDouble(lateLimitStr) * 60; } - String routeIdList = request.getParameter("r"); List routeIds = routeIdList == null ? null : Arrays.asList(routeIdList.split(",")); + Date beginDate; + try { + DateFormat defaultDateFormat = new SimpleDateFormat("MM-dd-yyyy"); + DateFormat altDateFormat = new SimpleDateFormat("yyyy-MM-dd"); + beginDate = (startDateStr.charAt(4) != '-') ? defaultDateFormat.parse(startDateStr) : altDateFormat.parse(startDateStr); + } catch (ParseException e) { + throw e; + } - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); - Date startDate = dateFormat.parse(startDateStr); - - List results = ScheduleAdherenceController.routeScheduleAdherenceSummary(startDate, - Integer.parseInt(numDaysStr), - startTime, endTime, - earlyLimit, lateLimit, - routeIds); - + Map results = scheduleAdherenceController.routeScheduleAdherenceSummary(beginDate, + Integer.parseInt(numDaysStr), + startTime, endTime, + earlyLimit, lateLimit, + routeIds); return ResponseEntity.ok(results); } - @Override public ResponseEntity predAccuracyScatterData(HttpServletRequest request) throws ParseException, SQLException { String agencyId = request.getParameter("a"); @@ -347,20 +357,6 @@ public ResponseEntity predAccuracyScatterData(HttpServletRequest request throw new ParseException("Number of days of " + numDays + " spans more than a month", 0); } - // Determine the time portion of the SQL - String timeSql = ""; - if ((beginTime != null && !beginTime.isEmpty()) || (endTime != null && !endTime.isEmpty())) { - // If only begin or only end time set then use default value - if (beginTime == null || beginTime.isEmpty()) { - beginTime = "00:00:00"; - } - if (endTime == null || endTime.isEmpty()) { - endTime = "23:59:59"; - } - - timeSql = SqlUtils.timeRangeClause(request, "arrival_depature_time", Integer.parseInt(numDays)); - } - // Determine route portion of SQL. Default is to provide info for all routes. String routeSql = ""; if (routeId != null && !routeId.trim().isEmpty()) { @@ -369,19 +365,19 @@ public ResponseEntity predAccuracyScatterData(HttpServletRequest request // Determine the source portion of the SQL. Default is to provide predictions for all sources String sourceSql = ""; - if (source != null && !source.isEmpty()) { + if (!Strings.isNullOrEmpty(source)) { if (source.equals("Transitime")) { // Only "Transitime" predictions - sourceSql = " AND prediction_source='Transitime'"; + sourceSql = " AND prediction_source = 'Transitime'"; } else { // Anything but "Transitime" - sourceSql = " AND prediction_source<>'Transitime'"; + sourceSql = " AND prediction_source <> 'Transitime'"; } } // Determine SQL for prediction type String predTypeSql = ""; - if (predictionType != null && !predictionType.isEmpty()) { + if (!Strings.isNullOrEmpty(predictionType)) { if (Objects.equals(source, "AffectedByWaitStop")) { // Only "AffectedByLayover" predictions predTypeSql = " AND affected_by_wait_stop = true "; @@ -417,23 +413,19 @@ public ResponseEntity predAccuracyScatterData(HttpServletRequest request String predLengthSql = " to_char(predicted_time-prediction_read_time, 'SSSS')::integer "; String predAccuracySql = " prediction_accuracy_msecs/1000 as predAccuracy "; - String sql = "SELECT " - + predLengthSql + " as predLength," - + predAccuracySql - + tooltipsSql - + " FROM prediction_accuracy " - + "WHERE " - + "1=1 " - + SqlUtils.timeRangeClause(request, "arrival_departure_time", 30) - + " AND " + predLengthSql + " < 900 " - + routeSql - + sourceSql - + predTypeSql - // Filter out MBTA_seconds source since it is isn't significantly different from MBTA_epoch. - // TODO should clean this up by not having MBTA_seconds source at all - // in the prediction accuracy module for MBTA. - + " AND prediction_source <> 'MBTA_seconds' "; + // Filter out MBTA_seconds source since it is isn't significantly different from MBTA_epoch. + // TODO should clean this up by not having MBTA_seconds source at all + // in the prediction accuracy module for MBTA. + String sql = "SELECT %s as predLength,%s%s FROM prediction_accuracy WHERE 1=1 %s AND %s < 900 %s%s%s AND prediction_source <> 'MBTA_seconds'".formatted( + predLengthSql, + predAccuracySql, + tooltipsSql, + SqlUtils.timeRangeClause(request, "arrival_departure_time", 30), + predLengthSql, + routeSql, + sourceSql, + predTypeSql); // Determine the json data by running the query String jsonString = ChartGenericJsonQuery.getJsonString(agencyId, sql); @@ -454,5 +446,4 @@ public ResponseEntity predAccuracyScatterData(HttpServletRequest request // Return the JSON data return ResponseEntity.ok(jsonString); } - } diff --git a/app/src/main/java/org/transitclock/core/reports/Reports.java b/app/src/main/java/org/transitclock/core/reports/Reports.java index 2379a28f8..2f210c743 100644 --- a/app/src/main/java/org/transitclock/core/reports/Reports.java +++ b/app/src/main/java/org/transitclock/core/reports/Reports.java @@ -1,16 +1,20 @@ /* (C)2023 */ package org.transitclock.core.reports; +import java.text.DateFormat; import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; + +import org.transitclock.domain.webstructs.WebAgency; + +import com.google.common.base.Strings; import org.json.JSONArray; import org.json.JSONObject; -import org.transitclock.domain.webstructs.WebAgency; -import org.transitclock.utils.Time; public class Reports { private static final int MAX_ROWS = 50000; - private static final int MAX_NUM_DAYS = 7; /** @@ -19,11 +23,12 @@ public class Reports { * * @param agencyId * @param vehicleId Which vehicle to get data for. Set to null or empty string to get data for - * all vehicles + * all vehicles * @param beginDate date to start query - * @param numdays of days to collect data for + * @param numdays of days to collect data for * @param beginTime optional time of day during the date range - * @param endTime optional time of day during the date range + * @param endTime optional time of day during the date range + * * @return AVL reports in JSON format. Can be empty JSON array if no data meets criteria. */ public static String getAvlJson( @@ -32,12 +37,16 @@ public static String getAvlJson( String timeSql = ""; WebAgency agency = WebAgency.getCachedWebAgency(agencyId); // If beginTime or endTime set but not both then use default values - if ((beginTime != null && !beginTime.isEmpty()) || (endTime != null && !endTime.isEmpty())) { - if (beginTime == null || beginTime.isEmpty()) beginTime = "00:00"; - if (endTime == null || endTime.isEmpty()) endTime = "24:00"; + if (Strings.isNullOrEmpty(beginTime) || Strings.isNullOrEmpty(endTime)) { + if (Strings.isNullOrEmpty(beginTime)) { + beginTime = "00:00"; + } + if (Strings.isNullOrEmpty(endTime)) { + endTime = "24:00"; + } } // cast('2000-01-01 01:12:00'::timestamp as time); - if (beginTime != null && !beginTime.isEmpty() && endTime != null && !endTime.isEmpty()) { + if (!Strings.isNullOrEmpty(beginTime) && !Strings.isNullOrEmpty(endTime)) { if ("mysql".equals(agency.getDbType())) { timeSql = " AND time(time) BETWEEN '" + beginTime + "' AND '" + endTime + "' "; } else { @@ -66,7 +75,9 @@ public static String getAvlJson( } // If only want data for single vehicle then specify so in SQL - if (vehicleId != null && !vehicleId.isEmpty()) sql += " AND vehicle_id='" + vehicleId + "' "; + if (vehicleId != null && !vehicleId.isEmpty()) { + sql += " AND vehicle_id='" + vehicleId + "' "; + } // Make sure data is ordered by vehicleId so that can draw lines // connecting the AVL reports per vehicle properly. Also then need @@ -76,14 +87,19 @@ public static String getAvlJson( sql += "ORDER BY vehicle_id, time LIMIT " + MAX_ROWS; - String json = null; + String json; + Date startdate; try { - java.util.Date startdate = Time.parseDate(beginDate); - + if (beginDate.charAt(4) != '-') { + DateFormat defaultDateFormat = new SimpleDateFormat("MM-dd-yyyy"); + startdate = defaultDateFormat.parse(beginDate); + } else { + DateFormat altDateFormat = new SimpleDateFormat("yyyy-MM-dd"); + startdate = altDateFormat.parse(beginDate); + } json = GenericJsonQuery.getJsonString(agencyId, sql, startdate, startdate); - } catch (ParseException e) { - json = e.getMessage(); + return e.getMessage(); } return json; @@ -238,10 +254,14 @@ public static String getScheduleAdhByStops( String beginTime, String endTime, int numDays) { - if (allowableEarly == null || allowableEarly.isEmpty()) allowableEarly = "1.0"; + if (allowableEarly == null || allowableEarly.isEmpty()) { + allowableEarly = "1.0"; + } String allowableEarlyMinutesStr = "'" + SqlUtils.convertMinutesToSecs(allowableEarly) + " seconds'"; - if (allowableLate == null || allowableLate.isEmpty()) allowableLate = "4.0"; + if (allowableLate == null || allowableLate.isEmpty()) { + allowableLate = "4.0"; + } String allowableLateMinutesStr = "'" + SqlUtils.convertMinutesToSecs(allowableLate) + " seconds'"; String sql = "WITH trips_early_query_with_time AS ( SELECT trip_id AS trips_early, " @@ -461,10 +481,14 @@ public static String getScheduleAdhByStops_v2( String beginTime, String endTime, int numDays) { - if (allowableEarly == null || allowableEarly.isEmpty()) allowableEarly = "1.0"; + if (allowableEarly == null || allowableEarly.isEmpty()) { + allowableEarly = "1.0"; + } String allowableEarlyMinutesStr = "'" + SqlUtils.convertMinutesToSecs(allowableEarly) + " seconds'"; - if (allowableLate == null || allowableLate.isEmpty()) allowableLate = "4.0"; + if (allowableLate == null || allowableLate.isEmpty()) { + allowableLate = "4.0"; + } String allowableLateMinutesStr = "'" + SqlUtils.convertMinutesToSecs(allowableLate) + " seconds'"; String sql = "WITH trips_early_query_with_time AS ( SELECT trip_id AS trips_early, " @@ -648,18 +672,22 @@ public static String getScheduleAdhByStops_v2( * * @return Stop reports in JSON format. Can be empty JSON array if no data meets criteria. */ - public static String getReportForStopById (String agencyId, - String stop, - String beginDate, - String allowableEarly, - String allowableLate, - String beginTime, - String endTime, - int numDays) { - if (allowableEarly == null || allowableEarly.isEmpty()) allowableEarly = "1.0"; + public static String getReportForStopById(String agencyId, + String stop, + String beginDate, + String allowableEarly, + String allowableLate, + String beginTime, + String endTime, + int numDays) { + if (allowableEarly == null || allowableEarly.isEmpty()) { + allowableEarly = "1.0"; + } String allowableEarlyMinutesStr = "'" + SqlUtils.convertMinutesToSecs(allowableEarly) + " seconds'"; - if (allowableLate == null || allowableLate.isEmpty()) allowableLate = "4.0"; + if (allowableLate == null || allowableLate.isEmpty()) { + allowableLate = "4.0"; + } String allowableLateMinutesStr = "'" + SqlUtils.convertMinutesToSecs(allowableLate) + " seconds'"; String sql = " WITH early AS (SELECT time AS early,\n" + @@ -754,7 +782,7 @@ public static String getReportForStopById (String agencyId, /** * Queries agency for AVL data and returns result as a JSON string. Limited to returning - + *

* MAX_ROWS (50,000) data points. * * @return Last AVL reports in JSON format. Can be empty JSON array if no data meets criteria. diff --git a/app/src/main/java/org/transitclock/core/reports/SqlUtils.java b/app/src/main/java/org/transitclock/core/reports/SqlUtils.java index 57a6746c6..9bb1cf2b6 100644 --- a/app/src/main/java/org/transitclock/core/reports/SqlUtils.java +++ b/app/src/main/java/org/transitclock/core/reports/SqlUtils.java @@ -2,13 +2,13 @@ package org.transitclock.core.reports; import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.util.StringUtils; +import java.text.ParseException; +import java.text.SimpleDateFormat; import org.transitclock.utils.Time; -import java.text.ParseException; -import java.text.SimpleDateFormat; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; /** * SQL utilities for creating SQL statements using parameters passed in to a page. Intended to make @@ -23,19 +23,20 @@ public class SqlUtils { * To be called on request parameters to make sure they don't contain any SQL injection * trickery. * - * @param parameter + * @param parameters * @throws RuntimeException if problem characters detected */ - public static void throwOnSqlInjection(String parameter) { - // If null then it is not a problem - if (parameter == null) { + public static void throwOnSqlInjection(String... parameters) { + if (parameters == null) { return; } - // If parameter contains a ' or a ; then throw error to - // prevent possible SQL injection attack - if (parameter.contains("'") || parameter.contains(";")) { - throw new IllegalArgumentException("Parameter \"" + parameter + "\" not valid."); + for (String parameter : parameters) { + // If parameter contains an ' or an ; then throw error to + // prevent possible SQL injection attack + if (parameter.contains("'") || parameter.contains(";")) { + throw new IllegalArgumentException("Parameter \"" + parameter + "\" not valid."); + } } } @@ -81,7 +82,7 @@ public static String routeIdentifiersList(String r) { * NULL AND (ad.routeshortname IN ('21','5') OR ad.routeid IN ('21','5') )" */ public static String routeClause(String r, String tableAliasName) { - if (StringUtils.isEmpty(r)) + if (StringUtils.hasText(r)) return ""; String routeIdentifiers = routeIdentifiersList(r); @@ -95,7 +96,7 @@ public static String routeClause(String r, String tableAliasName) { } public static String stopClause(String id, String tableAliasName) { - if (StringUtils.isEmpty(id)) + if (StringUtils.hasText(id)) return ""; String tableAlias = ""; @@ -117,10 +118,9 @@ public static String stopClause(String id, String tableAliasName) { */ public static String timeRangeClause(HttpServletRequest request, String timeColumnName, int maxNumDays) { String beginTime = request.getParameter("beginTime"); - throwOnSqlInjection(beginTime); - String endTime = request.getParameter("endTime"); - throwOnSqlInjection(endTime); + + throwOnSqlInjection(beginTime, endTime); // Determine the time portion of the SQL // If beginTime or endTime set but not both then use default values @@ -160,10 +160,9 @@ public static String timeRangeClause(HttpServletRequest request, String timeColu .formatted(timeColumnName, beginDateStr, endDateStr, timeSql); } else { // Not using dateRange so must be using beginDate and numDays params String beginDate = request.getParameter("beginDate"); - throwOnSqlInjection(beginDate); - String numDaysStr = request.getParameter("numDays"); - throwOnSqlInjection(numDaysStr); + + throwOnSqlInjection(beginDate, numDaysStr); if (numDaysStr == null) { numDaysStr = "1"; @@ -176,14 +175,8 @@ public static String timeRangeClause(HttpServletRequest request, String timeColu if (numDays > maxNumDays) { numDays = maxNumDays; } + beginDate = validateDate(beginDate); - SimpleDateFormat currentFormat = new SimpleDateFormat("MM-dd-yyyy"); - SimpleDateFormat requiredFormat = new SimpleDateFormat("yyyy-MM-dd"); - try { - beginDate = requiredFormat.format(currentFormat.parse(beginDate)); - } catch (ParseException e) { - logger.error("Exception occurred while processing time-range clause.", e); - } return " AND %s BETWEEN '%s' AND TIMESTAMP '%s' + INTERVAL '%d day' %s " .formatted(timeColumnName, beginDate, beginDate, numDays, timeSql); } @@ -193,7 +186,6 @@ public static String timeRangeClause(HttpServletRequest request, String timeColu * Creates a SQL clause for specifying a time range. Looks at the request parameters * "beginDate", "numDays", "beginTime", and "endTime" * - * @param request Http request containing parameters for the query * @param timeColumnName name of time column for that for query * @param maxNumDays maximum number of days for query. Request parameter numDays is limited to * this value in order to make sure that query doesn't try to process too much data. @@ -207,8 +199,7 @@ public static String timeRangeClause( String beginTime, String endTime, String beginDate) { - throwOnSqlInjection(beginTime); - throwOnSqlInjection(endTime); + throwOnSqlInjection(beginTime, endTime, beginDate); // If beginTime or endTime set but not both then use default values if (beginTime == null || beginTime.isEmpty()) { @@ -220,24 +211,13 @@ public static String timeRangeClause( String timeSql = " AND " + timeColumnName + "::time BETWEEN '" + beginTime + "' AND '" + endTime + "' "; - throwOnSqlInjection(beginDate); + beginDate = validateDate(beginDate); if (numDays > maxNumDays) { numDays = maxNumDays; } - SimpleDateFormat currentFormat = new SimpleDateFormat("MM-dd-yyyy"); - SimpleDateFormat requiredFormat = new SimpleDateFormat("yyyy-MM-dd"); - try { - if (beginDate.charAt(4) != '-') { // for two patterns MM-dd-yyyy & yyyy-MM-dd - beginDate = requiredFormat.format(currentFormat.parse(beginDate)); - } else { - requiredFormat.parse(beginDate); - } - } catch (ParseException e) { - logger.error("Exception happened while processing time-range clause", e); - } return " AND %s BETWEEN '%s' AND TIMESTAMP '%s' + INTERVAL '%d day' %s " .formatted(timeColumnName, beginDate, beginDate, numDays, timeSql); @@ -252,4 +232,19 @@ public static String timeRangeClause( public static int convertMinutesToSecs(String minutes) { return (int) Double.parseDouble(minutes) * Time.SEC_PER_MIN; } + + private static String validateDate(String date) { + SimpleDateFormat currentFormat = new SimpleDateFormat("MM-dd-yyyy"); + SimpleDateFormat requiredFormat = new SimpleDateFormat("yyyy-MM-dd"); + try { + if (date.charAt(4) != '-') { + date = requiredFormat.format(currentFormat.parse(date)); + } else { + requiredFormat.parse(date); + } + } catch (ParseException e) { + logger.error("Exception happened while processing time-range clause", e); + } + return date; + } } diff --git a/libs/core/src/main/java/org/transitclock/properties/WebProperties.java b/libs/core/src/main/java/org/transitclock/properties/WebProperties.java index a28e46896..d673e12a3 100644 --- a/libs/core/src/main/java/org/transitclock/properties/WebProperties.java +++ b/libs/core/src/main/java/org/transitclock/properties/WebProperties.java @@ -12,4 +12,16 @@ public class WebProperties { // For displaying as map attributing for the where map tiles from. private String mapTileCopyright = "MapQuest"; + // config param: transitclock.web.scheduleEarlyMinutes + // Schedule Adherence early limit + private Integer scheduleEarlyMinutes = -120; + + // config param: transitclock.web.scheduleLateMinutes + // Schedule Adherence late limit + private Integer scheduleLateMinutes = 420; + + // config param: transitclock.web.userPredictionLimits + // Use the allowable early/late report params or use configured schedule limits + private Boolean usePredictionLimits = true; + }