diff --git a/README.md b/README.md index 7fb9d26..7c12fc6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ When you [visit the app](https://trans-bdit.intra.prod-toronto.ca/traveltime-req * a time range, given in hours of the day, 00 - 23 * a date range (note that the end of the date range is exclusive) * a day of week selection -* _coming soon_! a selection of whether or not to include statutory holidays +* a selection of whether or not to include statutory holidays The app will combine these factors together to request travel times for all possible combinations. If one of each type of factor is selected, only a single travel time will be estimated with the given parameters. @@ -28,7 +28,7 @@ The app can return results in either CSV or JSON format. The fields in either ca * time range * date range * days of week -* holiday inclusion (will be added shortly) +* holiday inclusion The other fields may require some explanation: @@ -39,7 +39,6 @@ The other fields may require some explanation: | `hoursInRange` | The total number of hours that are theoretically within the scope of this request. This does not imply that data is/was available at all times. It's possible to construct requests with zero hours in range such as e.g `2023-01-01` to `2023-01-02`, Mondays only (There's only one Sunday in that range). Impossible combinations are included in the output for clarity and completeness but are not actually executed against the API and should return an error. | - ## Methodology Data for travel time estimation through the app are sourced from [HERE](https://github.com/CityofToronto/bdit_data-sources/tree/master/here)'s [traffic API](https://developer.here.com/documentation/traffic-api/api-reference.html) and are available back to about 2012. HERE collects data from motor vehicles that report their speed and position to HERE, most likely as a by-poduct of the driver making use of an in-car navigation system connected to the Internet. diff --git a/backend/app/routes.py b/backend/app/routes.py index fd57cca..e05a744 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -314,9 +314,7 @@ def aggregate_travel_times(start_node, end_node, start_time, end_time, start_dat # test URL /date-bounds @app.route('/date-bounds', methods=['GET']) def get_date_bounds(): - """ - Get the earliest date and latest data in the travel database. - """ + "Get the earliest date and latest data in the travel database." connection = getConnection() with connection: with connection.cursor() as cursor: @@ -327,3 +325,21 @@ def get_date_bounds(): "start_time": min_date.strftime('%Y-%m-%d'), "end_time": max_date.strftime('%Y-%m-%d') } + +# test URL /holidays +@app.route('/holidays', methods=['GET']) +def get_holidays(): + "Return dates of all known holidays in ascending order" + connection = getConnection() + with connection: + with connection.cursor() as cursor: + cursor.execute(""" + SELECT + dt::text, + holiday + FROM ref.holiday + ORDER BY dt; + """) + dates = [ {'date': dt, 'name': nm} for (dt, nm) in cursor.fetchall()] + connection.close() + return dates \ No newline at end of file diff --git a/frontend/src/Sidebar/index.jsx b/frontend/src/Sidebar/index.jsx index 0a5b9fe..e393f26 100644 --- a/frontend/src/Sidebar/index.jsx +++ b/frontend/src/Sidebar/index.jsx @@ -18,6 +18,8 @@ export default function SidebarContent(){
×
+
×
+
=
@@ -132,6 +134,57 @@ function DaysContainer(){ ) } +function HolidaysContainer(){ + const { logActivity, data } = useContext(DataContext) + let options = data.holidayOptions + let included, excluded + if(options.length == 1){ + included = options[0].holidaysIncluded + excluded = ! included + }else{ + included = true + excluded = true + } + function handleChange(option){ + if(option=='no'){ + data.excludeHolidays() + }else if(option=='yeah'){ + data.includeHolidays() + }else{ + data.includeAndExcludeHolidays() + } + logActivity(`include holidays? ${option}`) + } + return ( + +
Include holidays?
+ +
+ +
+ +
+ ) +} + function Welcome(){ return ( <>

Toronto Historic Travel Times

diff --git a/frontend/src/dateRange.js b/frontend/src/dateRange.js index 3c726d2..9da131a 100644 --- a/frontend/src/dateRange.js +++ b/frontend/src/dateRange.js @@ -48,27 +48,40 @@ export class DateRange extends Factor { get endDateFormatted(){ return DateRange.dateFormatted(this.#endDate) } - daysInRange(daysOptions){ // number of days covered by this dateRange - // TODO this will need to be revisited with the holiday options enabled + // number of days covered by this dateRange, considering DoW and holidays + daysInRange(daysOptions,holidayOptions){ + // TODO: this logic is pretty convoluted - clean it up!! if( ! (this.isComplete && daysOptions.isComplete) ){ return undefined } + let holidayDates = new Set(holidayOptions.holidays.map(h=>h.date)) // iterate each day in the range let d = new Date(this.#startDate.valueOf()) let dayCount = 0 + const holidaysExcluded = ! holidayOptions.holidaysIncluded while(d < this.#endDate){ - let dow = d.getUTCDay() + let dow = d.getUTCDay() let isodow = dow == 0 ? 7 : dow if( daysOptions.hasDay(isodow) ){ - dayCount ++ + // if holidays are NOT included, check the date isn't a holiday + if( ! ( holidaysExcluded && holidayDates.has(formatISODate(d)) ) ){ + dayCount ++ + } } - // incrememnt, modified in-place + // incrememnt one day, modified in-place d.setUTCDate(d.getUTCDate() + 1) } return dayCount } } +function formatISODate(dt){ // this is waaay too complicated... alas + let year = dt.getUTCFullYear() // should be good + let month = 1 + dt.getUTCMonth() // 0 - 11 -> 1 - 12 + let day = dt.getUTCDate() // 1 - 31 + return `${year}-${('0'+month).slice(-2)}-${('0'+day).slice(-2)}` +} + function DateRangeElement({dateRange}){ const [ startInput, setStartInput ] = useState(dateRange.startDateFormatted) const [ endInput, setEndInput ] = useState(dateRange.endDateFormatted) diff --git a/frontend/src/holidayOption.js b/frontend/src/holidayOption.js new file mode 100644 index 0000000..f9c9d35 --- /dev/null +++ b/frontend/src/holidayOption.js @@ -0,0 +1,17 @@ +import { Factor } from './factor.js' + +export class HolidayOption extends Factor { + #includeHolidays + #dataContext + constructor(dataContext,includeHolidays){ + super(dataContext) + // store this here too to actually access the holiday data + this.#dataContext = dataContext + // selection for whether to include holidays + this.#includeHolidays = includeHolidays + } + get holidaysIncluded(){ return this.#includeHolidays } + get holidays(){ + return this.#dataContext.holidays + } +} \ No newline at end of file diff --git a/frontend/src/spatialData.js b/frontend/src/spatialData.js index 77c8e00..359cc5c 100644 --- a/frontend/src/spatialData.js +++ b/frontend/src/spatialData.js @@ -2,22 +2,33 @@ import { Corridor } from './corridor.js' import { TimeRange } from './timeRange.js' import { DateRange } from './dateRange.js' import { Days } from './days.js' +import { HolidayOption } from './holidayOption.js' import { TravelTimeQuery } from './travelTimeQuery.js' +import { domain } from './domain.js' // instantiated once, this is the data store for all spatial and temporal data export class SpatialData { #factors = [] #queries = new Map() // store/cache for travelTimeQueries, letting them remember their results if any + #knownHolidays = [] constructor(){ this.#factors.push(new Days(this)) + this.#factors.push(new HolidayOption(this,true)) + fetch(`${domain}/holidays`) + .then( response => response.json() ) + .then( holidayList => this.#knownHolidays = holidayList ) } get corridors(){ return this.#factors.filter( f => f instanceof Corridor ) } get timeRanges(){ return this.#factors.filter( f => f instanceof TimeRange ) } get dateRanges(){ return this.#factors.filter( f => f instanceof DateRange ) } get days(){ return this.#factors.filter( f => f instanceof Days ) } + get holidayOptions(){ + return this.#factors.filter( f => f instanceof HolidayOption ) + } get activeCorridor(){ return this.corridors.find( cor => cor.isActive ) } + get holidays(){ return this.#knownHolidays } createCorridor(){ let corridor = new Corridor(this) this.#factors.push(corridor) @@ -52,6 +63,19 @@ export class SpatialData { if(f != factor) f.deactivate() } ) } + includeHolidays(){ + this.holidayOptions.forEach(f => this.dropFactor(f)) + this.#factors.push(new HolidayOption(this,true)) + } + excludeHolidays(){ + this.holidayOptions.forEach(f => this.dropFactor(f)) + this.#factors.push(new HolidayOption(this,false)) + } + includeAndExcludeHolidays(){ + this.holidayOptions.forEach(f => this.dropFactor(f)) + this.#factors.push(new HolidayOption(this,true)) + this.#factors.push(new HolidayOption(this,false)) + } get travelTimeQueries(){ // is the crossproduct of all complete/valid factors const crossProduct = [] @@ -59,9 +83,17 @@ export class SpatialData { this.timeRanges.filter(tr=>tr.isComplete).forEach( timeRange => { this.dateRanges.filter(dr=>dr.isComplete).forEach( dateRange => { this.days.filter(d=>d.isComplete).forEach( days => { - crossProduct.push( - new TravelTimeQuery({corridor,timeRange,dateRange,days}) - ) + this.holidayOptions.forEach( holidayOption => { + crossProduct.push( + new TravelTimeQuery({ + corridor, + timeRange, + dateRange, + days, + holidayOption + }) + ) + } ) } ) } ) } ) diff --git a/frontend/src/travelTimeQuery.js b/frontend/src/travelTimeQuery.js index 988fb78..d27cc15 100644 --- a/frontend/src/travelTimeQuery.js +++ b/frontend/src/travelTimeQuery.js @@ -5,12 +5,14 @@ export class TravelTimeQuery { #timeRange #dateRange #days + #holidayOption #travelTime - constructor({corridor,timeRange,dateRange,days}){ + constructor({corridor,timeRange,dateRange,days,holidayOption}){ this.#corridor = corridor this.#timeRange = timeRange this.#dateRange = dateRange this.#days = days + this.#holidayOption = holidayOption } get URI(){ let path = `${domain}/aggregate-travel-times` @@ -20,8 +22,10 @@ export class TravelTimeQuery { path += `/${this.#timeRange.startHour}/${this.#timeRange.endHour}` // start and end dates path += `/${this.#dateRange.startDateFormatted}/${this.#dateRange.endDateFormatted}` - // options not yet supported: holidays and days of week - path += `/true/${this.#days.apiString}` + // holiday inclusion + path += `/${this.#holidayOption.holidaysIncluded}` + // days of week + path += `/${this.#days.apiString}` return path } get corridor(){ return this.#corridor } @@ -29,7 +33,6 @@ export class TravelTimeQuery { get dateRange(){ return this.#dateRange } get days(){ return this.#days } async fetchData(){ - console.log(this.hoursInRange) if( this.hoursInRange < 1 ){ return this.#travelTime = -999 } @@ -43,7 +46,9 @@ export class TravelTimeQuery { return Boolean(this.#travelTime) } get hoursInRange(){ // number of hours covered by query options - return this.timeRange.hoursInRange * this.dateRange.daysInRange(this.days) + let hoursPerDay = this.timeRange.hoursInRange + let numDays = this.dateRange.daysInRange(this.days,this.#holidayOption) + return hoursPerDay * numDays } resultsRecord(type='json'){ const record = { @@ -52,6 +57,7 @@ export class TravelTimeQuery { timeRange: this.timeRange.name, dateRange: this.dateRange.name, daysOfWeek: this.days.name, + holidaysIncluded: this.#holidayOption.holidaysIncluded, hoursInRange: this.hoursInRange, mean_travel_time_minutes: this.#travelTime } @@ -71,6 +77,6 @@ export class TravelTimeQuery { return 'invalid type requested' } static csvHeader(){ - return 'URI,corridor,timeRange,dateRange,daysOfWeek,hoursPossible,mean_travel_time_minutes' + return 'URI,corridor,timeRange,dateRange,daysOfWeek,holidaysIncluded,hoursPossible,mean_travel_time_minutes' } } \ No newline at end of file