├── log └── .keep ├── storage └── .keep ├── tmp ├── .keep └── pids │ └── .keep ├── vendor └── .keep ├── lib ├── assets │ └── .keep ├── tasks │ └── .keep └── nyct-subway.pb.rb ├── public ├── favicon.ico ├── apple-touch-icon.png ├── apple-touch-icon-precomposed.png ├── robots.txt ├── .well-known │ ├── assetlinks.json │ └── apple-app-site-association ├── 500.html ├── 422.html └── 404.html ├── test ├── helpers │ └── .keep ├── mailers │ └── .keep ├── models │ └── .keep ├── system │ └── .keep ├── controllers │ └── .keep ├── integration │ └── .keep ├── fixtures │ └── files │ │ └── .keep ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb └── test_helper.rb ├── .ruby-version ├── app ├── assets │ ├── images │ │ ├── .keep │ │ ├── slack.gif │ │ ├── favicon.png │ │ ├── screenshot.png │ │ ├── slack-help.gif │ │ ├── apple-touch-icon.png │ │ └── cross-15.svg │ ├── config │ │ ├── manifest.js │ │ └── manifest.webmanifest │ └── stylesheets │ │ └── application.css ├── models │ ├── concerns │ │ └── .keep │ ├── application_record.rb │ ├── scheduled │ │ ├── route.rb │ │ ├── connection.rb │ │ ├── bus_transfer.rb │ │ ├── transfer.rb │ │ ├── calendar_exception.rb │ │ ├── schedule.rb │ │ ├── stop_time.rb │ │ ├── stop.rb │ │ └── trip.rb │ ├── service_changes │ │ ├── no_train_service_change.rb │ │ ├── not_scheduled_service_change.rb │ │ ├── truncated_service_change.rb │ │ ├── rerouting_service_change.rb │ │ ├── express_to_local_service_change.rb │ │ ├── local_to_express_service_change.rb │ │ ├── split_routing_service_change.rb │ │ └── service_change.rb │ ├── long_term_service_change_regular_routing.rb │ ├── delay_notification.rb │ └── trip.rb ├── controllers │ ├── concerns │ │ └── .keep │ ├── application_controller.rb │ ├── index_controller.rb │ ├── slack_controller.rb │ ├── api │ │ ├── stats_controller.rb │ │ ├── trips_controller.rb │ │ └── routes_controller.rb │ └── oauth_controller.rb ├── views │ ├── layouts │ │ ├── mailer.text.erb │ │ ├── mailer.html.erb │ │ └── application.html.erb │ ├── index │ │ └── index.html.erb │ └── slack │ │ ├── index.html.erb │ │ ├── help.html.erb │ │ └── privacy.html.erb ├── javascript │ ├── packs │ │ ├── components │ │ │ ├── twitterModal.scss │ │ │ ├── history.js │ │ │ ├── images │ │ │ │ └── googleplay.png │ │ │ ├── trainMap.scss │ │ │ ├── footer.scss │ │ │ ├── accessibility.scss │ │ │ ├── trainGrid.scss │ │ │ ├── stationList.scss │ │ │ ├── train.scss │ │ │ ├── trainModalOverviewPane.scss │ │ │ ├── trainModal.scss │ │ │ ├── trainBullet.scss │ │ │ ├── tripModal.scss │ │ │ ├── accessibility.jsx │ │ │ ├── train.jsx │ │ │ ├── trainBullet.jsx │ │ │ ├── utils.jsx │ │ │ ├── stationModal.scss │ │ │ ├── trainMapStop.scss │ │ │ ├── trainGrid.jsx │ │ │ ├── footer.jsx │ │ │ ├── trainModalOverviewPane.jsx │ │ │ ├── aboutModal.jsx │ │ │ ├── trainModalDirectionPane.scss │ │ │ └── app.scss │ │ ├── application.js │ │ └── vendor │ │ │ └── smartbanner.min.css │ └── channels │ │ ├── index.js │ │ └── consumer.js ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── helpers │ ├── application_helper.rb │ └── twitter_helper.rb ├── mailers │ └── application_mailer.rb ├── workers │ ├── routing_refresh_worker.rb │ ├── redis_cleanup_worker.rb │ ├── feed_retriever_spawning_a_worker.rb │ ├── feed_retriever_spawning_b1_worker.rb │ ├── feed_retriever_spawning_b2_worker.rb │ ├── feed_retriever_spawning_worker.rb │ ├── route_processor_worker.rb │ ├── feed_retriever_spawning_worker_base.rb │ ├── scheduled_times_refresh_worker.rb │ ├── travel_times_refresh_worker.rb │ ├── accessibility_list_worker.rb │ ├── accessibility_statuses_worker.rb │ ├── feed_retriever_worker.rb │ └── heroku_autoscaler_worker.rb └── jobs │ └── application_job.rb ├── .browserslistrc ├── Procfile ├── config ├── spring.rb ├── environment.rb ├── webpack │ ├── test.js │ ├── production.js │ ├── development.js │ └── environment.js ├── initializers │ ├── mime_types.rb │ ├── application_controller_renderer.rb │ ├── cookies_serializer.rb │ ├── filter_parameter_logging.rb │ ├── redis.rb │ ├── permissions_policy.rb │ ├── wrap_parameters.rb │ ├── backtrace_silencers.rb │ ├── assets.rb │ ├── inflections.rb │ ├── content_security_policy.rb │ ├── sidekiq.rb │ └── new_framework_defaults_6_1.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── locales │ └── en.yml ├── application.rb ├── storage.yml ├── routes.rb ├── puma.rb ├── webpacker.yml └── environments │ ├── test.rb │ ├── development.rb │ └── production.rb ├── bin ├── rake ├── rails ├── webpack ├── webpack-dev-server ├── spring ├── yarn ├── setup └── bundle ├── config.ru ├── db └── migrate │ ├── 20210314204626_drop_parent_stop_id_from_stops.rb │ ├── 20210314204824_add_secondary_name_to_stops.rb │ ├── 20240613040357_fix_sir_color.rb │ ├── 20240217002930_remove_broadway_jct_lirr_connection.rb │ ├── 20220102081218_add_latitude_and_longitude_to_stops.rb │ ├── 20210329005640_add_more_indexes.rb │ ├── 20211002063906_add_access_time_for_transfers.rb │ ├── 20210123052305_drop_directional_stops.rb │ ├── 20240102074539_update_fs_alternate_name.rb │ ├── 20211003215534_seed_missing_si_ferry_connection.rb │ ├── 20211003182053_seed_connection_for_gwb_bus_station.rb │ ├── 20210123172430_drop_n12_stop.rb │ ├── 20210122182736_remove_h19_station.rb │ ├── 20211015052833_seed_more_bus_transfers_and_connections.rb │ ├── 20211003014720_add_connections.rb │ ├── 20210123050349_migrate_stop_times.rb │ ├── 20220102081654_seed_stops_geolocations.rb │ ├── 20211015051225_add_unique_indexes_and_foreign_keys_to_bus_transfers_and_connections.rb │ ├── 20211002202553_add_bus_transfers.rb │ ├── 20240330050715_on_update_cascade_schedule_f_ks.rb │ ├── 20230309061906_seed_gcm_connections.rb │ ├── 20211002064045_seed_times_sq_to_bryant_pk_transfer.rb │ ├── 20240520022419_use_official_mta_colors.rb │ ├── 20201229201218_create_delayed_jobs.rb │ └── 20210102223417_initialize_scheduled_models.rb ├── import └── README.md ├── Rakefile ├── postcss.config.js ├── .gitattributes ├── package.json ├── .gitignore ├── LICENSE ├── babel.config.js └── Gemfile /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.3 2 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/index/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= react_component('App', {}, { class: 'app' }) %> -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec rails s 2 | worker: bundle exec sidekiq -q critical -q default -q low -c 5 -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /app/javascript/packs/components/twitterModal.scss: -------------------------------------------------------------------------------- 1 | .twitter-modal .small.route.bullet { 2 | margin-left: 0; 3 | } -------------------------------------------------------------------------------- /app/assets/images/slack.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blahblahblah-/subwaynow-server/HEAD/app/assets/images/slack.gif -------------------------------------------------------------------------------- /app/controllers/index_controller.rb: -------------------------------------------------------------------------------- 1 | class IndexController < ApplicationController 2 | def index 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /app/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blahblahblah-/subwaynow-server/HEAD/app/assets/images/favicon.png -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link ./manifest.webmanifest -------------------------------------------------------------------------------- /app/assets/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blahblahblah-/subwaynow-server/HEAD/app/assets/images/screenshot.png -------------------------------------------------------------------------------- /app/assets/images/slack-help.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blahblahblah-/subwaynow-server/HEAD/app/assets/images/slack-help.gif -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/javascript/packs/components/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export default createBrowserHistory(); -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /app/assets/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blahblahblah-/subwaynow-server/HEAD/app/assets/images/apple-touch-icon.png -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def title(page_title) 3 | content_for(:title) { page_title } 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | load File.expand_path("spring", __dir__) 3 | require_relative "../config/boot" 4 | require "rake" 5 | Rake.application.run 6 | -------------------------------------------------------------------------------- /app/javascript/packs/components/images/googleplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blahblahblah-/subwaynow-server/HEAD/app/javascript/packs/components/images/googleplay.png -------------------------------------------------------------------------------- /app/models/scheduled/route.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::Route < ActiveRecord::Base 2 | default_scope { order(name: :asc) } 3 | scope :visible, -> { where(visible: true)} 4 | end -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/controllers/slack_controller.rb: -------------------------------------------------------------------------------- 1 | class SlackController < ApplicationController 2 | def index 3 | end 4 | 5 | def help 6 | end 7 | 8 | def privacy 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /app/models/service_changes/no_train_service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::NoTrainServiceChange < ServiceChanges::ServiceChange 2 | def applicable_to_routing?(routing) 3 | true 4 | end 5 | end -------------------------------------------------------------------------------- /config/webpack/production.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /app/models/scheduled/connection.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::Connection < ActiveRecord::Base 2 | belongs_to :from_stop, class_name: "Stop", foreign_key: "from_stop_internal_id", primary_key: "internal_id" 3 | end -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /config/webpack/development.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /db/migrate/20210314204626_drop_parent_stop_id_from_stops.rb: -------------------------------------------------------------------------------- 1 | class DropParentStopIdFromStops < ActiveRecord::Migration[6.1] 2 | def change 3 | remove_column :stops, :parent_stop_id 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210314204824_add_secondary_name_to_stops.rb: -------------------------------------------------------------------------------- 1 | class AddSecondaryNameToStops < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :stops, :secondary_name, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/javascript/packs/components/trainMap.scss: -------------------------------------------------------------------------------- 1 | .train-map { 2 | ul { 3 | list-style-type: none; 4 | text-align: left; 5 | margin: auto; 6 | padding: 0; 7 | margin-bottom: .5em; 8 | } 9 | } -------------------------------------------------------------------------------- /app/models/service_changes/not_scheduled_service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::NotScheduledServiceChange < ServiceChanges::ServiceChange 2 | def applicable_to_routing?(routing) 3 | true 4 | end 5 | end -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | load File.expand_path("spring", __dir__) 3 | APP_PATH = File.expand_path('../config/application', __dir__) 4 | require_relative "../config/boot" 5 | require "rails/commands" 6 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 4 | driven_by :selenium, using: :chrome, screen_size: [1400, 1400] 5 | end 6 | -------------------------------------------------------------------------------- /config/webpack/environment.js: -------------------------------------------------------------------------------- 1 | const { environment } = require('@rails/webpacker') 2 | 3 | environment.loaders.get('sass').use.splice(-1, 0, { 4 | loader: 'resolve-url-loader', 5 | }); 6 | 7 | module.exports = environment 8 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /db/migrate/20240613040357_fix_sir_color.rb: -------------------------------------------------------------------------------- 1 | class FixSirColor < ActiveRecord::Migration[7.1] 2 | def change 3 | route = Scheduled::Route.find_by!(internal_id: "SI") 4 | route.color = "0039a6" 5 | route.save! 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /import/README.md: -------------------------------------------------------------------------------- 1 | # Import 2 | 3 | Please put the most up-to-date MTA static schedule data from http://web.mta.info/developers/developer-data-terms.html in this directory unzipped, as well as the the Stations Locations (Stations.csv) file. 4 | -------------------------------------------------------------------------------- /app/javascript/packs/components/footer.scss: -------------------------------------------------------------------------------- 1 | .footer.ui.vertical.segment { 2 | padding: 1em 2em; 3 | 4 | .ui.inverted.list .item a:not(.ui):hover, .footer .ui.inverted.list .item a:not(.ui):active { 5 | color: #ffe21f !important; 6 | } 7 | } -------------------------------------------------------------------------------- /app/workers/routing_refresh_worker.rb: -------------------------------------------------------------------------------- 1 | class RoutingRefreshWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'default' 4 | 5 | def perform 6 | ServiceChangeAnalyzer.refresh_routings(Time.current.to_i) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: goodservice_v2_production 11 | -------------------------------------------------------------------------------- /db/migrate/20240217002930_remove_broadway_jct_lirr_connection.rb: -------------------------------------------------------------------------------- 1 | class RemoveBroadwayJctLirrConnection < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::Connection.find_by(from_stop_internal_id: "A51", name: "LIRR")&.destroy 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/javascript/channels/index.js: -------------------------------------------------------------------------------- 1 | // Load all the channels within this directory and all subdirectories. 2 | // Channel files must be named *_channel.js. 3 | 4 | const channels = require.context('.', true, /_channel\.js$/) 5 | channels.keys().forEach(channels) 6 | -------------------------------------------------------------------------------- /db/migrate/20220102081218_add_latitude_and_longitude_to_stops.rb: -------------------------------------------------------------------------------- 1 | class AddLatitudeAndLongitudeToStops < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :stops, :latitude, :decimal 4 | add_column :stops, :longitude, :decimal 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/models/scheduled/bus_transfer.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::BusTransfer < ActiveRecord::Base 2 | belongs_to :from_stop, class_name: "Stop", foreign_key: "from_stop_internal_id", primary_key: "internal_id" 3 | 4 | def is_sbs? 5 | bus_route.include?("SBS") 6 | end 7 | end -------------------------------------------------------------------------------- /db/migrate/20210329005640_add_more_indexes.rb: -------------------------------------------------------------------------------- 1 | class AddMoreIndexes < ActiveRecord::Migration[6.1] 2 | def change 3 | add_index :trips, :schedule_service_id 4 | add_index :trips, :route_internal_id 5 | add_index :stop_times, :departure_time 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20211002063906_add_access_time_for_transfers.rb: -------------------------------------------------------------------------------- 1 | class AddAccessTimeForTransfers < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :transfers, :access_time_from, :integer 4 | add_column :transfers, :access_time_to, :integer 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20210123052305_drop_directional_stops.rb: -------------------------------------------------------------------------------- 1 | class DropDirectionalStops < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::Stop.where("internal_id like ?", "%N").destroy_all 4 | Scheduled::Stop.where("internal_id like ?", "%S").destroy_all 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20240102074539_update_fs_alternate_name.rb: -------------------------------------------------------------------------------- 1 | class UpdateFsAlternateName < ActiveRecord::Migration[6.1] 2 | def change 3 | route = Scheduled::Route.find_by!(internal_id: 'FS') 4 | route.alternate_name = 'Franklin Av Shuttle' 5 | route.save! 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/workers/redis_cleanup_worker.rb: -------------------------------------------------------------------------------- 1 | class RedisCleanupWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'low' 4 | 5 | def perform 6 | RedisStore.clear_outdated_trips 7 | RedisStore.clear_outdated_trip_stops_and_delays 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20211003215534_seed_missing_si_ferry_connection.rb: -------------------------------------------------------------------------------- 1 | class SeedMissingSiFerryConnection < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::Connection.create!(from_stop_internal_id: "S31", name: "SI Ferry", mode: "ship", min_transfer_time: 300) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /db/migrate/20211003182053_seed_connection_for_gwb_bus_station.rb: -------------------------------------------------------------------------------- 1 | class SeedConnectionForGwbBusStation < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::Connection.create!(from_stop_internal_id: "A07", name: "GWB Bus Station", mode: "bus", min_transfer_time: 300) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /app/models/scheduled/transfer.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::Transfer < ActiveRecord::Base 2 | belongs_to :from_stop, class_name: "Stop", foreign_key: "from_stop_internal_id", primary_key: "internal_id" 3 | belongs_to :to_stop, class_name: "Stop", foreign_key: "to_stop_internal_id", primary_key: "internal_id" 4 | end -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/models/service_changes/truncated_service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::TruncatedServiceChange < ServiceChanges::ServiceChange 2 | def applicable_to_routing?(routing) 3 | if begin_of_route? 4 | destinations.include?(routing.last) 5 | else 6 | routing.last == first_station 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/javascript/channels/consumer.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. 3 | 4 | import { createConsumer } from "@rails/actioncable" 5 | 6 | export default createConsumer() 7 | -------------------------------------------------------------------------------- /app/javascript/packs/components/accessibility.scss: -------------------------------------------------------------------------------- 1 | .accessible-icon { 2 | i.icons { 3 | .blue.accessible.icon { 4 | margin-right: .25em !important; 5 | } 6 | 7 | .corner.icon { 8 | text-shadow: none; 9 | } 10 | } 11 | 12 | &.not-accessible { 13 | margin-left: .5em; 14 | } 15 | } -------------------------------------------------------------------------------- /app/models/service_changes/rerouting_service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::ReroutingServiceChange < ServiceChanges::ServiceChange 2 | def applicable_to_routing?(routing) 3 | if begin_of_route? 4 | destinations.include?(routing.last) 5 | else 6 | routing.include?(first_station) 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [ 5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 6 | ] 7 | -------------------------------------------------------------------------------- /app/assets/config/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Subway Now lite", 3 | "short_name": "Subway Now lite", 4 | "start_url": "https://lite.subwaynow.app", 5 | "display": "fullscreen", 6 | "background_color": "#000000", 7 | "description": "New York City Subway Status Page", 8 | "scope": "https://lite.subwaynow.app/" 9 | } -------------------------------------------------------------------------------- /app/models/service_changes/express_to_local_service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::ExpressToLocalServiceChange < ServiceChanges::ServiceChange 2 | def convert_to_rerouting 3 | ServiceChanges::ReroutingServiceChange.new( 4 | self.direction, self.stations_affected, self.origin, self.routing, self.long_term_override 5 | ) 6 | end 7 | end -------------------------------------------------------------------------------- /test/channels/application_cable/connection_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase 4 | # test "connects with cookies" do 5 | # cookies.signed[:user_id] = 42 6 | # 7 | # connect 8 | # 9 | # assert_equal connection.user_id, "42" 10 | # end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/api/stats_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::StatsController < ApplicationController 2 | def index 3 | data = { 4 | feeds: RedisStore.all_feed_latencies.transform_values(&:to_f), 5 | routes: RedisStore.all_processed_route_latencies.transform_values(&:to_f), 6 | } 7 | render json: data 8 | end 9 | end -------------------------------------------------------------------------------- /db/migrate/20210123172430_drop_n12_stop.rb: -------------------------------------------------------------------------------- 1 | class DropN12Stop < ActiveRecord::Migration[6.1] 2 | def change 3 | # N12 is an internal station for S.B. Coney Island 4 | Scheduled::Stop.find_by(internal_id: 'N12')&.destroy 5 | Scheduled::Stop.find_by(internal_id: 'N12N')&.destroy 6 | Scheduled::Stop.find_by(internal_id: 'N12S')&.destroy 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20210122182736_remove_h19_station.rb: -------------------------------------------------------------------------------- 1 | class RemoveH19Station < ActiveRecord::Migration[6.1] 2 | def change 3 | # H19 is an internal station for Broad Channel 4 | Scheduled::Stop.find_by(internal_id: 'H19')&.destroy 5 | Scheduled::Stop.find_by(internal_id: 'H19N')&.destroy 6 | Scheduled::Stop.find_by(internal_id: 'H19S')&.destroy 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark the yarn lockfile as having been generated. 7 | yarn.lock linguist-generated 8 | 9 | # Mark any vendored files as having been vendored. 10 | vendor/* linguist-vendored 11 | -------------------------------------------------------------------------------- /app/models/service_changes/local_to_express_service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::LocalToExpressServiceChange < ServiceChanges::ServiceChange 2 | CLOSED_STOPS = ENV['CLOSED_STOPS']&.split(',')&.map {|s| s[0..2]} || [] 3 | 4 | def not_long_term? 5 | !long_term? 6 | end 7 | 8 | def long_term? 9 | (intermediate_stations - CLOSED_STOPS).empty? 10 | end 11 | end -------------------------------------------------------------------------------- /config/initializers/redis.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | require 'uri' 3 | 4 | if Rails.env == 'production' 5 | if ENV['REDIS_URL'] 6 | REDIS_CLIENT = Redis.new(url: ENV["REDIS_URL"], ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE }) 7 | else 8 | REDIS_CLIENT = Redis.new(url: ENV['REDISCLOUD_URL']) 9 | end 10 | else 11 | REDIS_CLIENT = Redis.new(url: ENV['REDIS_URL']) 12 | end 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/.well-known/assetlinks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "relation": ["delegate_permission/common.handle_all_urls"], 4 | "target": { 5 | "namespace": "android_app", 6 | "package_name": "io.goodservice.theweekendest", 7 | "sha256_cert_fingerprints": 8 | ["90:8E:AB:7B:BD:65:D0:34:2B:06:A9:52:08:FC:86:07:7F:6E:BE:00:DE:FE:51:E9:44:37:4E:D3:16:70:DB:72"] 9 | } 10 | } 11 | ] -------------------------------------------------------------------------------- /db/migrate/20211015052833_seed_more_bus_transfers_and_connections.rb: -------------------------------------------------------------------------------- 1 | class SeedMoreBusTransfersAndConnections < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::BusTransfer.create!(from_stop_internal_id: "J12", bus_route: "Q10 to JFK", min_transfer_time: 300, airport_connection: true) 4 | Scheduled::Connection.create!(from_stop_internal_id: "418", name: "PATH", mode: "subway", min_transfer_time: 300) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | require_relative "../config/environment" 3 | require "rails/test_help" 4 | 5 | class ActiveSupport::TestCase 6 | # Run tests in parallel with specified workers 7 | parallelize(workers: :number_of_processors) 8 | 9 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 10 | fixtures :all 11 | 12 | # Add more helper methods to be used by all tests here... 13 | end 14 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /app/workers/feed_retriever_spawning_a_worker.rb: -------------------------------------------------------------------------------- 1 | class FeedRetrieverSpawningAWorker < FeedRetrieverSpawningWorkerBase 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'critical' 4 | 5 | FEEDS = [""] 6 | 7 | def perform 8 | minutes = Time.current.min 9 | fraction_of_minute = Time.current.sec / 5 10 | FEEDS.each do |id| 11 | FeedRetrieverWorker.perform_async(id, minutes, fraction_of_minute) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | w6zKdzfXW94pP2aGOaMRIa/++sJu8EvO6gyMBN674agpF1zg/nq0FWfO8/sHd3VWMh1x+ttZhVUze9j8dKyYwenidpUmy55gwkwTjLdSJ0+RhqcpkK5KYdz/2QF6CT/CJQ/jXnRYEqYMhR961ZE0V93axTpjd5Ec6/zNMRHTO+dzX7G1f9jUFXp0jBeLo3ZPbMhJ6UIZg/aO213/RSlzX9B/LcR4DHzSB7Soim1J9Fxiz/ezFJ/01s7jrV8R63jvrhdM9fKNNo+GWbTIRlrMxKyJYu7pN8hWSEtWy6Z87+mOIvhuJRM9lYkitPWUvP9uaH3lNGiso9HcDRLG7JPvQxIvylFtfLv5wwM7nb0V/f1mhBDkedSlgJvkhrlB28Y7c1W5idk/vvBJgLvNxAI4I6lHGs0vEGtOgC92--omEhN3W83HQhnwkh--UlTSpJ8ILMzc0RWZv7pEyg== -------------------------------------------------------------------------------- /app/workers/feed_retriever_spawning_b1_worker.rb: -------------------------------------------------------------------------------- 1 | class FeedRetrieverSpawningB1Worker < FeedRetrieverSpawningWorkerBase 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'critical' 4 | 5 | FEEDS = ["-ace", "-bdfm", "-g"] 6 | 7 | def perform 8 | minutes = Time.current.min 9 | fraction_of_minute = Time.current.sec / 15 10 | FEEDS.each do |id| 11 | FeedRetrieverWorker.perform_async(id, minutes, fraction_of_minute) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/workers/feed_retriever_spawning_b2_worker.rb: -------------------------------------------------------------------------------- 1 | class FeedRetrieverSpawningB2Worker < FeedRetrieverSpawningWorkerBase 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'critical' 4 | 5 | FEEDS = ["-jz", "-nqrw", "-l", "-si"] 6 | 7 | def perform 8 | minutes = Time.current.min 9 | fraction_of_minute = Time.current.sec / 15 10 | FEEDS.each do |id| 11 | FeedRetrieverWorker.perform_async(id, minutes, fraction_of_minute) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/webpack_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::WebpackRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /app/javascript/packs/components/trainGrid.scss: -------------------------------------------------------------------------------- 1 | .ui.grid.train-grid { 2 | @media only screen and (max-width: 767px) { 3 | display: none; 4 | } 5 | } 6 | 7 | .ui.grid+.grid.mobile-train-grid { 8 | @media only screen and (min-width: 768px) { 9 | display: none; 10 | } 11 | 12 | margin-top: 0; 13 | 14 | .status { 15 | font-weight: 200; 16 | } 17 | 18 | .train-status-row { 19 | padding-bottom: 0; 20 | } 21 | 22 | .row:first-child { 23 | padding-top: 0; 24 | } 25 | } -------------------------------------------------------------------------------- /app/models/scheduled/calendar_exception.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::CalendarException < ActiveRecord::Base 2 | belongs_to :schedule, foreign_key: "schedule_service_id", primary_key: "service_id" 3 | 4 | def self.next_weekday 5 | date = Date.current 6 | while [0, 6].include?(date.wday) || 7 | where(date: date).where("schedule_service_id like ?", "%Sunday%").or(where(date: date).where("schedule_service_id like ?", "%Saturday%")).present? 8 | date += 1 9 | end 10 | date 11 | end 12 | end -------------------------------------------------------------------------------- /bin/webpack-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/dev_server_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::DevServerRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /app/workers/feed_retriever_spawning_worker.rb: -------------------------------------------------------------------------------- 1 | class FeedRetrieverSpawningWorker < FeedRetrieverSpawningWorkerBase 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'critical' 4 | 5 | FEEDS = ["", "-bdfm", "-ace", "-jz", "-nqrw", "-l", "-si", "-g"] 6 | 7 | def perform 8 | minutes = Time.current.min 9 | fraction_of_minute = Time.current.sec / 15 10 | FEEDS.each do |id| 11 | FeedRetrieverWorker.perform_async(id, minutes, fraction_of_minute) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/workers/route_processor_worker.rb: -------------------------------------------------------------------------------- 1 | class RouteProcessorWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'critical' 4 | 5 | def perform(route_id, timestamp) 6 | marshaled_trips = RedisStore.route_trips(route_id, timestamp) 7 | trips = Marshal.load(marshaled_trips) if marshaled_trips 8 | 9 | if !trips 10 | raise "Error: Trips for #{route_id} at #{Time.zone.at(timestamp)} not found" 11 | end 12 | 13 | RouteProcessor.process_route(route_id, trips, timestamp) 14 | end 15 | end -------------------------------------------------------------------------------- /db/migrate/20211003014720_add_connections.rb: -------------------------------------------------------------------------------- 1 | class AddConnections < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :connections, force: :cascade do |t| 4 | t.string :from_stop_internal_id, null: false 5 | t.string :name, null: false 6 | t.string :mode 7 | t.integer :min_transfer_time, default: 0, null: false 8 | t.integer :access_time_from 9 | t.integer :access_time_to 10 | t.index ["from_stop_internal_id"], name: "index_connections_on_from_stop_internal_id" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"]) 3 | gem "bundler" 4 | require "bundler" 5 | 6 | # Load Spring without loading other gems in the Gemfile, for speed. 7 | Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring| 8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 9 | gem "spring", spring.version 10 | require "spring/binstub" 11 | rescue Gem::LoadError 12 | # Ignore when Spring is not installed. 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20210123050349_migrate_stop_times.rb: -------------------------------------------------------------------------------- 1 | class MigrateStopTimes < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::Stop.where("internal_id like ?", "%N").each do |stop| 4 | Scheduled::StopTime.where("stop_internal_id = ?", stop.internal_id).update_all(stop_internal_id: stop.internal_id[0..2]) 5 | end 6 | Scheduled::Stop.where("internal_id like ?", "%S").each do |stop| 7 | Scheduled::StopTime.where("stop_internal_id = ?", stop.internal_id).update_all(stop_internal_id: stop.internal_id[0..2]) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /db/migrate/20220102081654_seed_stops_geolocations.rb: -------------------------------------------------------------------------------- 1 | class SeedStopsGeolocations < ActiveRecord::Migration[6.1] 2 | def change 3 | csv_text = File.read(Rails.root.join('import', 'Stations.csv')) 4 | csv = CSV.parse(csv_text, headers: true) 5 | csv.each do |row| 6 | stop_id = row['GTFS Stop ID'] 7 | stop = Scheduled::Stop.find_by!(internal_id: stop_id) 8 | stop.latitude = row['GTFS Latitude'] 9 | stop.longitude = row['GTFS Longitude'] 10 | stop.save! 11 | puts "Geolocation for #{stop_id} saved" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/workers/feed_retriever_spawning_worker_base.rb: -------------------------------------------------------------------------------- 1 | class FeedRetrieverSpawningWorkerBase 2 | ALL_FEEDS = ["", "-ace", "-bdfm", "-g", "-jz", "-nqrw", "-l", "-si"] 3 | MISC_FEED_MAPPING = { 4 | "FS" => "-bdfm", 5 | "GS" => "", 6 | "H" => "-ace", 7 | "SI" => "-si", 8 | "SS" => "-si", 9 | } 10 | 11 | def self.feed_id_for(route_id) 12 | if MISC_FEED_MAPPING[route_id] 13 | MISC_FEED_MAPPING[route_id] 14 | elsif feed_id = ALL_FEEDS.find { |f| f.upcase.include?(route_id.first) } 15 | feed_id 16 | else 17 | "" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /db/migrate/20211015051225_add_unique_indexes_and_foreign_keys_to_bus_transfers_and_connections.rb: -------------------------------------------------------------------------------- 1 | class AddUniqueIndexesAndForeignKeysToBusTransfersAndConnections < ActiveRecord::Migration[6.1] 2 | def change 3 | add_index :bus_transfers, [:from_stop_internal_id, :bus_route], unique: true 4 | add_index :connections, [:from_stop_internal_id, :name], unique: true 5 | 6 | add_foreign_key :bus_transfers, :stops, column: :from_stop_internal_id, primary_key: :internal_id 7 | add_foreign_key :connections, :stops, column: :from_stop_internal_id, primary_key: :internal_id 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR). 5 | select { |dir| File.expand_path(dir) != __dir__ }. 6 | product(["yarn", "yarn.cmd", "yarn.ps1"]). 7 | map { |dir, file| File.expand_path(file, dir) }. 8 | find { |file| File.executable?(file) } 9 | 10 | if yarn 11 | exec yarn, *ARGV 12 | else 13 | $stderr.puts "Yarn executable was not detected in the system." 14 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 15 | exit 1 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20211002202553_add_bus_transfers.rb: -------------------------------------------------------------------------------- 1 | class AddBusTransfers < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :bus_transfers, force: :cascade do |t| 4 | t.string :from_stop_internal_id, null: false 5 | t.string :bus_route, null: false 6 | t.integer :min_transfer_time, default: 0, null: false 7 | t.integer :access_time_from 8 | t.integer :access_time_to 9 | t.boolean :airport_connection, default: false, null: false 10 | t.index ["from_stop_internal_id"], name: "index_bus_transfers_on_from_stop_internal_id" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20240330050715_on_update_cascade_schedule_f_ks.rb: -------------------------------------------------------------------------------- 1 | class OnUpdateCascadeScheduleFKs < ActiveRecord::Migration[6.1] 2 | def change 3 | remove_foreign_key :calendar_exceptions, :schedules, column: :schedule_service_id 4 | remove_foreign_key :trips, :schedules, column: :schedule_service_id 5 | add_foreign_key :calendar_exceptions, :schedules, column: :schedule_service_id, primary_key: :service_id, on_update: :cascade, on_delete: :cascade 6 | add_foreign_key :trips, :schedules, column: :schedule_service_id, primary_key: :service_id, on_update: :cascade, on_delete: :cascade 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This file is automatically compiled by Webpack, along with any other files 2 | // present in this directory. You're encouraged to place your actual application logic in 3 | // a relevant structure within app/javascript and only use these pack files to reference 4 | // that code so it'll be compiled. 5 | 6 | import 'core-js/stable' 7 | import 'regenerator-runtime/runtime' 8 | import React from 'react'; 9 | import ReactDOM from 'react-dom'; 10 | import App from './components/app.jsx'; 11 | import WebpackerReact from 'webpacker-react' 12 | 13 | WebpackerReact.setup({App}) 14 | -------------------------------------------------------------------------------- /db/migrate/20230309061906_seed_gcm_connections.rb: -------------------------------------------------------------------------------- 1 | class SeedGcmConnections < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::Connection.create!(from_stop_internal_id: "631", name: "LIRR", mode: "train", min_transfer_time: 300, access_time_from: 19800, access_time_to: 86399) 4 | Scheduled::Connection.create!(from_stop_internal_id: "723", name: "LIRR", mode: "train", min_transfer_time: 300, access_time_from: 19800, access_time_to: 86399) 5 | Scheduled::Connection.create!(from_stop_internal_id: "901", name: "LIRR", mode: "train", min_transfer_time: 300, access_time_from: 19800, access_time_to: 86399) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/javascript/packs/components/stationList.scss: -------------------------------------------------------------------------------- 1 | .station-list { 2 | .results .results-list-item { 3 | .cross { 4 | height: 21px; 5 | width: 21px; 6 | margin: .25em; 7 | margin-top: 0; 8 | } 9 | 10 | .ui.header { 11 | i.icon.pin, i.icon.location { 12 | display: inline; 13 | font-size: 1em; 14 | 15 | &:only-child { 16 | margin-right: 0.25rem; 17 | } 18 | } 19 | 20 | .secondary-name { 21 | color: #aaaaaa; 22 | font-weight: 300!important; 23 | margin-left: .4rem; 24 | font-size: 0.9em; 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/controllers/oauth_controller.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "net/http" 3 | 4 | class OauthController < ApplicationController 5 | 6 | SLACK_OAUTH_URI = "https://slack.com/api/oauth.access" 7 | 8 | def index 9 | code = params[:code] 10 | 11 | post_params = { 12 | client_id: ENV["SLACK_CLIENT_ID"], 13 | client_secret: ENV["SLACK_CLIENT_SECRET"], 14 | code: code 15 | } 16 | data = Net::HTTP.post_form(URI.parse(SLACK_OAUTH_URI), post_params) 17 | 18 | redirect_to ENV["SLACK_REDIRECT_URI"], allow_other_host: true 19 | end 20 | 21 | def slack_install 22 | redirect_to ENV["SLACK_DIRECT_INSTALL_URI"], allow_other_host: true 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | -------------------------------------------------------------------------------- /app/models/scheduled/schedule.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::Schedule < ActiveRecord::Base 2 | has_many :calendar_exceptions, foreign_key: "schedule_service_id", primary_key: "service_id" 3 | scope :day, ->(day_of_the_week) { where("#{day_of_the_week} = 1")} 4 | 5 | def self.today(date: Date.current) 6 | joins("LEFT OUTER JOIN calendar_exceptions ON schedules.service_id ="\ 7 | "calendar_exceptions.schedule_service_id and calendar_exceptions.date = '#{date}'" 8 | ).where("(calendar_exceptions.exception_type = 1 or "\ 9 | "(#{Date::DAYNAMES[(date).wday].downcase} = 1 and calendar_exceptions.exception_type is null) and 10 | start_date <= '#{date}' and end_date >= '#{date}')") 11 | end 12 | end -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: postgresql 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: goodservice-v2_development 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: goodservice-v2_test 22 | 23 | production: 24 | url: <%= ENV['DATABASE_URL'] %> -------------------------------------------------------------------------------- /app/assets/images/cross-15.svg: -------------------------------------------------------------------------------- 1 | cross-15.svg -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/workers/scheduled_times_refresh_worker.rb: -------------------------------------------------------------------------------- 1 | class ScheduledTimesRefreshWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'default' 4 | 5 | def perform 6 | stop_pairs_map = {} 7 | Scheduled::Route.all.each do |route| 8 | Scheduled::Trip.soon_grouped(Time.current.to_i, route.id).each do |_, trips| 9 | trips.each do |trip| 10 | trip.stop_times.each_cons(2) do |a_st, b_st| 11 | time = b_st.departure_time - a_st.departure_time 12 | stop_pairs_map[[a_st.stop_internal_id, b_st.stop_internal_id]] = time 13 | end 14 | end 15 | end 16 | end 17 | 18 | REDIS_CLIENT.pipelined do |pipeline| 19 | stop_pairs_map.each do |(a_st, b_st), time| 20 | RedisStore.add_scheduled_travel_time(a_st, b_st, time, pipeline) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/javascript/packs/components/train.scss: -------------------------------------------------------------------------------- 1 | button.ui.inverted.secondary.button.train { 2 | color: white; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | height: 100%; 7 | 8 | .status { 9 | font-weight: 200; 10 | } 11 | 12 | .ui.header { 13 | color: white; 14 | } 15 | 16 | &:hover, &:active, .active, &:focus { 17 | background-color: rgba(0,0,0, 0.3); 18 | color: gray; 19 | 20 | .ui.header { 21 | color: gray; 22 | } 23 | } 24 | 25 | .train-bullet { 26 | float: left; 27 | 28 | .alternate-name { 29 | margin-left: .2em; 30 | text-align: left; 31 | } 32 | } 33 | 34 | @media only screen and (max-width: 767px) { 35 | padding: 0; 36 | border: none; 37 | background: none; 38 | min-width: 2em; 39 | } 40 | 41 | @media only screen and (min-width: 768px) { 42 | min-height: 8em; 43 | } 44 | } -------------------------------------------------------------------------------- /app/workers/travel_times_refresh_worker.rb: -------------------------------------------------------------------------------- 1 | class TravelTimesRefreshWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false, queue: 'default' 4 | 5 | def perform 6 | routes = Scheduled::Route.all 7 | route_futures = {} 8 | 9 | REDIS_CLIENT.pipelined do |pipeline| 10 | route_futures = routes.to_h do |r| 11 | [r.internal_id, RedisStore.route_status(r.internal_id, pipeline)] 12 | end 13 | end 14 | 15 | stop_pairs = route_futures.flat_map { |_, rf| 16 | data = rf.value && JSON.parse(rf.value) 17 | data && data['actual_routings']&.flat_map do |_, routings| 18 | routings.flat_map { |r| r.each_cons(2).to_a } 19 | end 20 | }.compact.uniq 21 | 22 | travel_times = RouteProcessor.batch_average_travel_time_pairs(stop_pairs) 23 | data = Marshal.dump(travel_times) 24 | RedisStore.update_travel_times(data) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "goodservice-v2", 3 | "private": true, 4 | "engines": { 5 | "node": "14.x" 6 | }, 7 | "dependencies": { 8 | "@babel/preset-react": "^7.12.10", 9 | "@rails/actioncable": "^6.0.0", 10 | "@rails/activestorage": "^6.0.0", 11 | "@rails/ujs": "^6.0.0", 12 | "@rails/webpacker": "5.2.1", 13 | "es-cookie": "^1.3.2", 14 | "lodash": "^4.17.20", 15 | "react": "^17.0.1", 16 | "react-dom": "^17.0.1", 17 | "react-helmet": "^6.1.0", 18 | "react-hot-loader": "4", 19 | "react-router-dom": "^5.2.0", 20 | "resolve-url-loader": "^3.1.2", 21 | "semantic-ui-css": "^2.4.1", 22 | "semantic-ui-react": "^2.0.2", 23 | "turbolinks": "^5.2.0", 24 | "webpacker-react": "^0.3.2" 25 | }, 26 | "version": "0.1.0", 27 | "devDependencies": { 28 | "@babel/plugin-transform-react-jsx": "^7.12.12", 29 | "webpack-dev-server": "^3.11.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/javascript/packs/components/trainModalOverviewPane.scss: -------------------------------------------------------------------------------- 1 | .ui.segment.train-modal-overview-pane { 2 | @media only screen and (max-width: 767px) { 3 | padding-left: 0; 4 | padding-right: 0; 5 | } 6 | 7 | .ui.stackable.grid>.row>.column.map-cell { 8 | @media only screen and (max-width: 767px) { 9 | display: none; 10 | } 11 | } 12 | 13 | .ui.stackable.grid>.row>.column.mobile-map-cell { 14 | @media only screen and (min-width: 768px) { 15 | display: none; 16 | } 17 | } 18 | 19 | .ui.stackable.grid>.row>.column.status-cell { 20 | @media only screen and (max-width: 767px) { 21 | margin-left: -1rem !important; 22 | margin-right: -1rem !important; 23 | 24 | padding-left: 0 !important; 25 | padding-right: 0 !important; 26 | } 27 | } 28 | 29 | .ui.small.statistics .statistic>.value { 30 | @media only screen and (max-width: 767px) { 31 | font-size: 2rem!important; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/controllers/api/trips_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::TripsController < ApplicationController 2 | def show 3 | trip_id = params[:id].sub("-", "..") 4 | marshaled_trip = RedisStore.active_trip(FeedRetrieverSpawningWorkerBase.feed_id_for(params[:route_id]), trip_id) 5 | 6 | if !marshaled_trip && trip_id.include?("..") 7 | trip_id = trip_id.sub("..", ".") 8 | marshaled_trip = RedisStore.active_trip(FeedRetrieverSpawningWorkerBase.feed_id_for(params[:route_id]), trip_id) 9 | end 10 | 11 | raise ActionController::RoutingError.new('Not Found') unless marshaled_trip 12 | trip = Marshal.load(marshaled_trip) 13 | 14 | data = { 15 | route_id: trip.route_id, 16 | trip_id: trip.id, 17 | stop_times: trip.stops, 18 | tracks: trip.tracks, 19 | past_stops: trip.past_stops, 20 | is_assigned: trip.is_assigned, 21 | timestamp: trip.timestamp 22 | } 23 | 24 | expires_now 25 | render json: data 26 | end 27 | end -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Custom 8 | /import/*.txt 9 | /import/*.csv 10 | 11 | # Ignore bundler config. 12 | /.bundle 13 | 14 | # Ignore the default SQLite database. 15 | /db/*.sqlite3 16 | /db/*.sqlite3-* 17 | 18 | # Ignore all logfiles and tempfiles. 19 | /log/* 20 | /tmp/* 21 | !/log/.keep 22 | !/tmp/.keep 23 | 24 | # Ignore pidfiles, but keep the directory. 25 | /tmp/pids/* 26 | !/tmp/pids/ 27 | !/tmp/pids/.keep 28 | 29 | # Ignore uploaded files in development. 30 | /storage/* 31 | !/storage/.keep 32 | 33 | /public/assets 34 | .byebug_history 35 | 36 | # Ignore master key for decrypting credentials and more. 37 | /config/master.key 38 | 39 | /public/packs 40 | /public/packs-test 41 | /node_modules 42 | /yarn-error.log 43 | yarn-debug.log* 44 | .yarn-integrity 45 | 46 | .DS_Store 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sunny Ng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /db/migrate/20211002064045_seed_times_sq_to_bryant_pk_transfer.rb: -------------------------------------------------------------------------------- 1 | class SeedTimesSqToBryantPkTransfer < ActiveRecord::Migration[6.1] 2 | def change 3 | Scheduled::Transfer.create!(from_stop_internal_id: "D16", to_stop_internal_id: "902", min_transfer_time: 300, access_time_from: 21600, access_time_to: 86399) 4 | Scheduled::Transfer.create!(from_stop_internal_id: "D16", to_stop_internal_id: "R16", min_transfer_time: 420, access_time_from: 21600, access_time_to: 86399) 5 | Scheduled::Transfer.create!(from_stop_internal_id: "D16", to_stop_internal_id: "127", min_transfer_time: 420, access_time_from: 21600, access_time_to: 86399) 6 | Scheduled::Transfer.create!(from_stop_internal_id: "902", to_stop_internal_id: "D16", min_transfer_time: 300, access_time_from: 21600, access_time_to: 86399) 7 | Scheduled::Transfer.create!(from_stop_internal_id: "R16", to_stop_internal_id: "D16", min_transfer_time: 420, access_time_from: 21600, access_time_to: 86399) 8 | Scheduled::Transfer.create!(from_stop_internal_id: "127", to_stop_internal_id: "D16", min_transfer_time: 420, access_time_from: 21600, access_time_to: 86399) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20240520022419_use_official_mta_colors.rb: -------------------------------------------------------------------------------- 1 | class UseOfficialMtaColors < ActiveRecord::Migration[7.1] 2 | def change 3 | color_map = { 4 | '1' => 'ee352e', 5 | '2' => 'ee352e', 6 | '3' => 'ee352e', 7 | '4' => '00933c', 8 | '5' => '00933c', 9 | '6' => '00933c', 10 | '6X' => '00933c', 11 | '7' => 'b933ad', 12 | '7X' => 'b933ad', 13 | 'GS' => '6d6e71', 14 | 'A' => '2850ad', 15 | 'B' => 'ff6319', 16 | 'C' => '2850ad', 17 | 'D' => 'ff6319', 18 | 'E' => '2850ad', 19 | 'F' => 'ff6319', 20 | 'FX' => 'ff6319', 21 | 'FS' => '6d6e71', 22 | 'G' => '6cbe45', 23 | 'J' => '996633', 24 | 'L' => 'a7a9ac', 25 | 'M' => 'ff6319', 26 | 'N' => 'fccc0a', 27 | 'Q' => 'fccc0a', 28 | 'R' => 'fccc0a', 29 | 'H' => '6d6e71', 30 | 'W' => 'fccc0a', 31 | 'Z' => '996633', 32 | 'SI' => '00396a', 33 | } 34 | color_map.each do |route_id, color| 35 | route = Scheduled::Route.find_by!(internal_id: route_id) 36 | route.color = color 37 | route.save! 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module GoodserviceV2 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 7.1 13 | 14 | config.add_autoload_paths_to_load_path = false 15 | config.active_support.cache_format_version = 7.1 16 | 17 | # Configuration for the application, engines, and railties goes here. 18 | # 19 | # These settings can be overridden in specific environments using the files 20 | # in config/environments, which are processed later. 21 | # 22 | # config.time_zone = "Central Time (US & Canada)" 23 | # config.eager_load_paths << Rails.root.join("extras") 24 | config.time_zone = 'America/New_York' 25 | 26 | config.middleware.insert_before 0, Rack::Cors do 27 | allow do 28 | origins '*' 29 | resource '*', 30 | headers: :any, 31 | methods: %i(get) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies 21 | system! 'bin/yarn' 22 | 23 | # puts "\n== Copying sample files ==" 24 | # unless File.exist?('config/database.yml') 25 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 26 | # end 27 | 28 | puts "\n== Preparing database ==" 29 | system! 'bin/rails db:prepare' 30 | 31 | puts "\n== Removing old logs and tempfiles ==" 32 | system! 'bin/rails log:clear tmp:clear' 33 | 34 | puts "\n== Restarting application server ==" 35 | system! 'bin/rails restart' 36 | end 37 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /app/models/scheduled/stop_time.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::StopTime < ActiveRecord::Base 2 | belongs_to :trip, foreign_key: "trip_internal_id", primary_key: "internal_id" 3 | belongs_to :stop, foreign_key: "stop_internal_id", primary_key: "internal_id" 4 | 5 | DAY_IN_MINUTES = 86400 6 | BUFFER = 10800 7 | 8 | def self.rounded_time 9 | Time.current.change(sec: 0) 10 | end 11 | 12 | def self.not_past(current_timestamp: rounded_time.to_i) 13 | current_time = Time.zone.at(current_timestamp) 14 | if (current_time + BUFFER).to_date == current_time.to_date.tomorrow 15 | where("departure_time > ? or (? - departure_time > ?)", 16 | current_time - current_time.beginning_of_day, 17 | current_time - current_time.beginning_of_day, 18 | BUFFER, 19 | ) 20 | elsif current_time.hour < 4 21 | where("(departure_time < ? and departure_time > ?) or (departure_time >= ? and departure_time - ? > ?)", 22 | DAY_IN_MINUTES - BUFFER, 23 | current_time - current_time.beginning_of_day, 24 | DAY_IN_MINUTES, 25 | DAY_IN_MINUTES, 26 | current_time - current_time.beginning_of_day 27 | ) 28 | else 29 | where("departure_time > ?", current_time - current_time.beginning_of_day) 30 | end 31 | end 32 | end -------------------------------------------------------------------------------- /app/workers/accessibility_list_worker.rb: -------------------------------------------------------------------------------- 1 | class AccessibilityListWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: 1, queue: 'low' 4 | 5 | FEED_URI = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fnyct_ene_equipments.json" 6 | 7 | def perform 8 | feed_data = retrieve_feed 9 | update_accessible_stops_list(feed_data) 10 | end 11 | 12 | private 13 | 14 | def retrieve_feed 15 | uri = URI.parse(FEED_URI) 16 | Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| 17 | request = Net::HTTP::Get.new uri 18 | request["x-api-key"] = ENV["MTA_KEY"] 19 | 20 | response = http.request request 21 | JSON.parse(response.body).select { |h| h['equipmenttype'] == 'EL' && h['ADA'] == 'Y' } 22 | end 23 | end 24 | 25 | def update_accessible_stops_list(data) 26 | accessible_stations = Set.new 27 | elevator_map = {} 28 | 29 | data.each do |elevator| 30 | stations = elevator['elevatorsgtfsstopid'].split('/') 31 | stations.each do |s| 32 | accessible_stations << s 33 | end 34 | elevator_map[elevator['equipmentno']] = stations 35 | end 36 | 37 | RedisStore.update_accessible_stops_list(accessible_stations.to_a.to_json) 38 | RedisStore.update_elevator_map(elevator_map.to_json) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/workers/accessibility_statuses_worker.rb: -------------------------------------------------------------------------------- 1 | class AccessibilityStatusesWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: 1, queue: 'low' 4 | 5 | FEED_URI = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fnyct_ene.json" 6 | 7 | def perform 8 | feed_data = retrieve_feed 9 | update_elevator_advisories(feed_data) 10 | end 11 | 12 | private 13 | 14 | def retrieve_feed 15 | uri = URI.parse(FEED_URI) 16 | Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| 17 | request = Net::HTTP::Get.new uri 18 | request["x-api-key"] = ENV["MTA_KEY"] 19 | 20 | response = http.request request 21 | JSON.parse(response.body).select{ |s| s['equipmenttype'] == 'EL' && s['ADA'] == 'Y' && s['isupcomingoutage'] == 'N' } 22 | end 23 | end 24 | 25 | def update_elevator_advisories(data) 26 | elevator_map_json = RedisStore.elevator_map 27 | return unless elevator_map_json 28 | elevator_map = JSON.parse(elevator_map_json) 29 | 30 | advisories = Hash.new { |h, k| h[k] = [] } 31 | data.each do |status| 32 | elevator_map[status['equipment']]&.flatten&.each do |station| 33 | advisories[station] << HTMLEntities.new.decode(status['serving']) 34 | end 35 | end 36 | 37 | RedisStore.update_elevator_advisories(advisories.to_json) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/javascript/packs/components/trainModal.scss: -------------------------------------------------------------------------------- 1 | .ui.fullscreen.modal.active.train-modal { 2 | @media only screen and (max-width: 767px) { 3 | width: 100% !important; 4 | } 5 | 6 | .close.icon { 7 | color: white; 8 | } 9 | 10 | .header { 11 | border: 0; 12 | 13 | .ui.grid { 14 | @media only screen and (max-width: 767px) { 15 | margin-top: 1em; 16 | } 17 | 18 | .train-name-cell { 19 | @media only screen and (max-width: 767px) { 20 | padding-right: 0; 21 | } 22 | } 23 | 24 | .train-direction-nav-cell { 25 | @media only screen and (max-width: 767px) { 26 | padding-left: 0; 27 | } 28 | 29 | .ui.menu.header-menu { 30 | .item.no-service { 31 | text-decoration: line-through; 32 | color: #999999; 33 | } 34 | 35 | @media only screen and (max-width: 767px) { 36 | margin-left: 1em!important; 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | .scrolling.content { 44 | padding-top: 0!important; 45 | 46 | .description { 47 | padding-top: 0!important; 48 | } 49 | } 50 | 51 | &.blurring { 52 | filter: blur(5px) grayscale(.7); 53 | } 54 | 55 | .ui.basic.inverted.segment { 56 | .ui.yellow.top.attached.label { 57 | color: black !important; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /db/migrate/20201229201218_create_delayed_jobs.rb: -------------------------------------------------------------------------------- 1 | class CreateDelayedJobs < ActiveRecord::Migration[6.1] 2 | def self.up 3 | create_table :delayed_jobs do |table| 4 | table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue 5 | table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually. 6 | table.text :handler, null: false # YAML-encoded string of the object that will do work 7 | table.text :last_error # reason for last failure (See Note below) 8 | table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future. 9 | table.datetime :locked_at # Set when a client is working on this object 10 | table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) 11 | table.string :locked_by # Who is working on this object (if locked) 12 | table.string :queue # The name of the queue this job is in 13 | table.timestamps null: true 14 | end 15 | 16 | add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority" 17 | end 18 | 19 | def self.down 20 | drop_table :delayed_jobs 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /public/.well-known/apple-app-site-association: -------------------------------------------------------------------------------- 1 | { 2 | "applinks": { 3 | "apps": [], 4 | "details": [ 5 | { 6 | "appIDs": ["LT5JAC55XG.io.goodservice.The-Weekendest"], 7 | "components": [ 8 | { 9 | "/": "/*", 10 | "comment": "Matches any URL" 11 | }, 12 | { 13 | "/": "/api/*", 14 | "exclude": true, 15 | "comment": "Exclude APIs" 16 | }, 17 | { 18 | "/": "/about", 19 | "exclude": true, 20 | "comment": "Exclude About" 21 | }, 22 | { 23 | "/": "/twitter", 24 | "exclude": true, 25 | "comment": "Exclude Twitter" 26 | }, 27 | { 28 | "/": "/slack", 29 | "exclude": true, 30 | "comment": "Exclude Slack" 31 | }, 32 | { 33 | "/": "/slack/*", 34 | "exclude": true, 35 | "comment": "Exclude Slack" 36 | } 37 | ] 38 | } 39 | ] 40 | } 41 | } -------------------------------------------------------------------------------- /app/models/service_changes/split_routing_service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::SplitRoutingServiceChange < ServiceChanges::ServiceChange 2 | attr_accessor :routing_tuples, :related_routes_by_segments 3 | 4 | def initialize(direction, routing_tuples, long_term_override = false) 5 | self.direction = direction 6 | self.affects_some_trains = false 7 | self.routing_tuples = routing_tuples 8 | self.related_routes_by_segments = {} 9 | self.long_term_override = long_term_override 10 | end 11 | 12 | def match?(comparing_change) 13 | routing_tuples.size == comparing_change.routing_tuples.size && 14 | routing_tuples.all? { |r| comparing_change.routing_tuples.any? { |c| c.first == r.last && c.last == r.first }} 15 | end 16 | 17 | def begin_of_route? 18 | false 19 | end 20 | 21 | def end_of_route? 22 | false 23 | end 24 | 25 | def applicable_to_routing?(_) 26 | true 27 | end 28 | 29 | def hash 30 | self.class.hash ^ self.direction.hash ^ self.routing_tuples.hash 31 | end 32 | 33 | def ==(other) 34 | self.class == other.class && self.direction == other.direction && self.routing_tuples == other.routing_tuples 35 | end 36 | 37 | def as_json(options = {}) 38 | { 39 | type: self.class.name.demodulize, 40 | stations_affected: stations_affected, 41 | related_routes: related_routes, 42 | routing_tuples: routing_tuples, 43 | long_term: !not_long_term?, 44 | } 45 | end 46 | end -------------------------------------------------------------------------------- /lib/nyct-subway.pb.rb: -------------------------------------------------------------------------------- 1 | require 'protobuf' 2 | require 'google/transit/gtfs-realtime.pb' 3 | 4 | module Transit_realtime 5 | 6 | # forward declarations 7 | class TripReplacementPeriod < ::Protobuf::Message; end 8 | class NyctFeedHeader < ::Protobuf::Message; end 9 | class NyctTripDescriptor < ::Protobuf::Message; end 10 | class NyctStopTimeUpdate < ::Protobuf::Message; end 11 | 12 | class TripReplacementPeriod < ::Protobuf::Message 13 | optional :string, :route_id, 1 14 | optional TimeRange, :replacement_period, 2 15 | end 16 | 17 | class NyctFeedHeader < ::Protobuf::Message 18 | required :string, :nyct_subway_version, 1 19 | repeated TripReplacementPeriod, :trip_replacement_period, 2 20 | end 21 | 22 | class NyctTripDescriptor < ::Protobuf::Message 23 | # forward declarations 24 | 25 | # enums 26 | class Direction < ::Protobuf::Enum 27 | define :NORTH, 1 28 | define :EAST, 2 29 | define :SOUTH, 3 30 | define :WEST, 4 31 | end 32 | 33 | optional :string, :train_id, 1 34 | optional :bool, :is_assigned, 2 35 | optional Direction, :direction, 3 36 | end 37 | 38 | class TripDescriptor 39 | optional NyctTripDescriptor, :nyct_trip_descriptor, 1001 40 | end 41 | 42 | class NyctStopTimeUpdate < ::Protobuf::Message 43 | optional :string, :scheduled_track, 1 44 | optional :string, :actual_track, 2 45 | end 46 | 47 | class TripUpdate::StopTimeUpdate 48 | optional NyctStopTimeUpdate, :nyct_stop_time_update, 1001 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | # # If you are using webpack-dev-server then specify webpack-dev-server host 15 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? 16 | 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | 21 | # If you are using UJS then enable automatic nonce generation 22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 23 | 24 | # Set the nonce only to specific directives 25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 26 | 27 | # Report CSP violations to a specified URI 28 | # For further information see the following documentation: 29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 30 | # Rails.application.config.content_security_policy_report_only = true 31 | -------------------------------------------------------------------------------- /app/javascript/packs/components/trainBullet.scss: -------------------------------------------------------------------------------- 1 | .route { 2 | width: 1.5em; 3 | height: 1.5em; 4 | font-size: 2.25em; 5 | line-height: 1.5em; 6 | text-align: center; 7 | font-weight: bold; 8 | font-style: normal!important; 9 | color: #fff; 10 | margin: .25em; 11 | 12 | &.medium { 13 | height: 1.75em; 14 | width: 1.75em; 15 | font-size: 1.75em; 16 | line-height: 1.75em; 17 | } 18 | 19 | &.small { 20 | height: 1.5em; 21 | width: 1.5em; 22 | font-size: 1em; 23 | line-height: 1.5em; 24 | display: inline-block; 25 | } 26 | 27 | &.condensed { 28 | font-stretch: condensed; 29 | } 30 | } 31 | 32 | .bullet { 33 | -webkit-border-radius: 999px; 34 | -moz-border-radius: 999px; 35 | border-radius: 999px; 36 | font-family: Helvetica Neue, Helvetica, Arial, Sans-serif, Open Sans; 37 | 38 | &.downtown-only { 39 | border-top-left-radius: 0; 40 | border-top-right-radius: 0; 41 | border-bottom-left-radius: .8em; 42 | border-bottom-right-radius: .8em; 43 | height: 0.8em; 44 | line-height: 0.1em; 45 | } 46 | 47 | &.uptown-only { 48 | border-top-left-radius: .8em; 49 | border-top-right-radius: .8em; 50 | border-bottom-left-radius: 0; 51 | border-bottom-right-radius: 0; 52 | height: 0.8em; 53 | } 54 | } 55 | 56 | .diamond { 57 | transform:rotate(45deg); 58 | -ms-transform:rotate(45deg); 59 | -webkit-transform:rotate(45deg); 60 | } 61 | 62 | .diamond-inner { 63 | transform:rotate(-45deg); 64 | -ms-transform:rotate(-45deg); 65 | -webkit-transform:rotate(-45deg); 66 | } -------------------------------------------------------------------------------- /app/javascript/packs/components/tripModal.scss: -------------------------------------------------------------------------------- 1 | .ui.large.modal.active.trip-modal { 2 | @media only screen and (max-width: 767px) { 3 | width: 100% !important; 4 | } 5 | 6 | .modal-header { 7 | @media only screen and (max-width: 767px) { 8 | font-size: 1.2em; 9 | } 10 | 11 | .route { 12 | float: left; 13 | } 14 | 15 | .trip-header-info { 16 | float: left; 17 | margin-top: .5em; 18 | 19 | .delayed-header { 20 | margin-top: 0; 21 | } 22 | } 23 | 24 | .past-stops-toggle { 25 | float: right; 26 | clear: both; 27 | 28 | @media only screen and (min-width: 768px) { 29 | margin-right: 1em; 30 | } 31 | 32 | .toggle-label { 33 | color: #ccc !important; 34 | } 35 | 36 | .ui.toggle.checkbox input:checked~label.toggle-label, .ui.toggle.checkbox input:focus:checked~label.toggle-label { 37 | color: #ccc !important; 38 | } 39 | 40 | .ui.toggle.checkbox .box:before, .ui.toggle.checkbox label.toggle-label:before, .ui.toggle.checkbox input:focus~.box:before, .ui.toggle.checkbox input:focus~label.toggle-label:before, .ui.toggle.checkbox .box:hover::before, .ui.toggle.checkbox label.toggle-label:hover::before { 41 | background: rgba(255,255,255,.05) 42 | } 43 | } 44 | } 45 | 46 | .description { 47 | margin-top: 0; 48 | padding-top: 0!important; 49 | 50 | .divider { 51 | margin-top: 0; 52 | } 53 | 54 | .trip-table { 55 | .past { 56 | color: #aaaaaa; 57 | } 58 | .delayed { 59 | color: #ff695e; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | require "sidekiq/web" # require the web UI 2 | require "sidekiq/cron/web" # require cron tab UI 3 | 4 | Rails.application.routes.draw do 5 | Sidekiq::Web.use Rack::Auth::Basic do |username, password| 6 | # Protect against timing attacks: 7 | # - See https://codahale.com/a-lesson-in-timing-attacks/ 8 | # - See https://thisdata.com/blog/timing-attacks-against-string-comparison/ 9 | # - Use & (do not use &&) so that it doesn't short circuit. 10 | # - Use digests to stop length information leaking (see also ActiveSupport::SecurityUtils.variable_size_secure_compare) 11 | ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), ::Digest::SHA256.hexdigest(ENV["SIDEKIQ_USERNAME"])) & 12 | ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), ::Digest::SHA256.hexdigest(ENV["SIDEKIQ_PASSWORD"])) 13 | end if Rails.env.production? 14 | mount Sidekiq::Web, at: "/sidekiq" 15 | namespace :api do 16 | resources :routes, only: [:index, :show] do 17 | resources :trips, only: [:show] 18 | end 19 | resources :stops, only: [:index, :show] 20 | post '/slack', to: 'slack#index' 21 | post '/slack/query', to: 'slack#query' 22 | post '/alexa', to: 'alexa#index' 23 | get '/stats', to: 'stats#index' 24 | end 25 | get '/about', to: 'index#index' 26 | get '/twitter', to: 'index#index' 27 | get '/trains(/*id)', to: 'index#index' 28 | get '/stations(/*id)', to: 'index#index' 29 | get '/oauth', to: 'oauth#index' 30 | get '/slack', to: 'slack#index' 31 | get '/slack/help', to: 'slack#help' 32 | get '/slack/privacy', to: 'slack#privacy' 33 | get '/slack/install', to: 'oauth#slack_install' 34 | root 'index#index' 35 | end 36 | -------------------------------------------------------------------------------- /app/javascript/packs/components/accessibility.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icon } from "semantic-ui-react"; 3 | 4 | import './accessibility.scss'; 5 | 6 | export const accessibilityIcon = (accessibility) => { 7 | if (!accessibility) { 8 | return; 9 | } 10 | 11 | const accessibleNorth = accessibility.directions.includes('north'); 12 | const accessibleSouth = accessibility.directions.includes('south'); 13 | 14 | if (accessibility.advisories.length > 0) { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | if (accessibleNorth && accessibleSouth) { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | if (accessibleNorth && !accessibleSouth) { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | if (!accessibleNorth && accessibleSouth) { 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | } -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /app/models/scheduled/stop.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::Stop < ActiveRecord::Base 2 | has_many :stop_times, foreign_key: "stop_internal_id", primary_key: "internal_id" 3 | 4 | PREFIX_ABBREVIATIONS = { 5 | "st" => "saint", 6 | "ft" => "fort", 7 | } 8 | 9 | ABBREVIATIONS = { 10 | "&" => "and", 11 | # "n" => "north", 12 | "ne" => "northeast", 13 | "nw" => "northwest", 14 | "e" => "east", 15 | "s" => "south", 16 | "se" => "southeast", 17 | "sw" => "southwest", 18 | "w" => "west", 19 | "lex" => "lexington", 20 | "st" => "street", 21 | "sts" => "streets", 22 | "av" => "avenue", 23 | "ave" => "avenue", 24 | "avs" => "avenues", 25 | "hwy" => "highway", 26 | "rd" => "road", 27 | "dr" => "drive", 28 | "ln" => "lane", 29 | "ny" => "new york", 30 | "blvd" => "boulevard", 31 | "pk" => "park", 32 | "sq" => "square", 33 | "pkwy" => "parkway", 34 | "hts" => "heights", 35 | "ctr" => "center", 36 | "tpke" => "turnpike", 37 | "jct" => "junction", 38 | "ext" => "extension", 39 | "pl" => "place", 40 | } 41 | 42 | def normalized_full_name(separator: "") 43 | secondary_name ? "#{self.class.normalized_partial_name(stop_name)} #{separator} #{self.class.normalized_partial_name(secondary_name)}" : self.class.normalized_partial_name(stop_name) 44 | end 45 | 46 | def normalized_name 47 | self.class.normalized_partial_name(stop_name) 48 | end 49 | 50 | def self.normalized_partial_name(name) 51 | array = name.downcase.split(" - ") 52 | str = array.map { |s| 53 | if PREFIX_ABBREVIATIONS[s[0...2]] && s[2] == ' ' 54 | s.sub(s[0...2], PREFIX_ABBREVIATIONS[s[0...2]]) 55 | else 56 | s 57 | end 58 | }.join(" ") 59 | 60 | ABBREVIATIONS.each do |k, v| 61 | str.gsub!(/\b#{k}\b/, v) 62 | end 63 | str.gsub(/[^a-zA-Z0-9 ]/, " ").gsub(/\s\s+/, ' ').gsub(/([0-9]+)/) {|n| "#{n}#{n.to_i.ordinal}" } 64 | end 65 | end -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/workers/feed_retriever_worker.rb: -------------------------------------------------------------------------------- 1 | require 'nyct-subway.pb' 2 | require 'net/http' 3 | require 'uri' 4 | 5 | class FeedRetrieverWorker 6 | include Sidekiq::Worker 7 | sidekiq_options retry: 1, dead: false, queue: 'critical' 8 | 9 | BASE_URI = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs" 10 | 11 | def perform(feed_id, minutes, fraction_of_minute) 12 | current_minute = Time.current.min 13 | if (current_minute - minutes) > 1 14 | puts "Job expired, noop" 15 | return 16 | end 17 | puts "Retrieving feed #{feed_id}" 18 | 19 | uri = URI.parse("#{BASE_URI}#{feed_id}") 20 | decoded_data = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| 21 | request = Net::HTTP::Get.new uri 22 | request["x-api-key"] = ENV["MTA_KEY"] 23 | 24 | response = http.request request 25 | data = response.body 26 | Transit_realtime::FeedMessage.decode(data) 27 | end 28 | last_feed_timestamp = RedisStore.feed_timestamp(feed_id) 29 | timestamp = decoded_data.header.timestamp 30 | puts "Feed #{feed_id} has timestamp #{timestamp}, last timestamp #{last_feed_timestamp}" 31 | if last_feed_timestamp.present? && last_feed_timestamp.to_i == timestamp.to_i 32 | puts "Skipping feed #{feed_id} with timestamp #{last_feed_timestamp} has not been updated" 33 | return 34 | end 35 | RedisStore.update_feed_timestamp(feed_id, timestamp) 36 | 37 | time = Time.zone.at(timestamp) 38 | latency = Time.current - time 39 | puts "Retrieved feed #{feed_id}, latency #{latency}, timestamp #{time.strftime("%S")}" 40 | RedisStore.update_feed_latency(feed_id, latency) 41 | 42 | route_ids = decoded_data.entity.select { |entity| 43 | entity.field?(:trip_update) 44 | }.map { |entity| 45 | entity.trip_update.trip.route_id 46 | }.uniq 47 | 48 | route_ids.each do |route_id| 49 | FeedProcessor.analyze_feed(feed_id, route_id, minutes, fraction_of_minute, decoded_data) 50 | end 51 | end 52 | end -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/models/service_changes/service_change.rb: -------------------------------------------------------------------------------- 1 | class ServiceChanges::ServiceChange 2 | attr_accessor :direction, :stations_affected, :related_routes, :affects_some_trains, :origin, :destinations, :routing, :long_term_override 3 | 4 | def initialize(direction, stations_affected, origin, routing, long_term_override = false) 5 | self.direction = direction 6 | self.stations_affected = stations_affected 7 | self.affects_some_trains = false 8 | self.origin = origin 9 | self.routing = routing 10 | self.destinations = [routing&.last].compact 11 | self.long_term_override = long_term_override 12 | end 13 | 14 | def first_station 15 | stations_affected.first || stations_affected.second 16 | end 17 | 18 | def last_station 19 | stations_affected.last || stations_affected.second_to_last 20 | end 21 | 22 | def begin_of_route? 23 | stations_affected.first.nil? 24 | end 25 | 26 | def end_of_route? 27 | stations_affected.last.nil? 28 | end 29 | 30 | def intermediate_stations 31 | stations_affected - [first_station, last_station] 32 | end 33 | 34 | def applicable_to_routing?(routing) 35 | [first_station, last_station].all? { |s| routing.include?(s) } 36 | end 37 | 38 | def hash 39 | self.class.hash ^ self.direction.hash ^ self.stations_affected.first.hash ^ self.stations_affected.last.hash 40 | end 41 | 42 | def ==(other) 43 | self.class == other.class && self.direction == other.direction && self.stations_affected.first == other.stations_affected.first && self.stations_affected.last == other.stations_affected.last 44 | end 45 | 46 | def eql?(other) 47 | self == other 48 | end 49 | 50 | def as_json(options = {}) 51 | { 52 | type: self.class.name.demodulize, 53 | stations_affected: stations_affected, 54 | related_routes: related_routes, 55 | long_term: !not_long_term?, 56 | } 57 | end 58 | 59 | def not_long_term? 60 | !self.long_term_override 61 | end 62 | 63 | def long_term? 64 | !!self.long_term_override 65 | end 66 | end -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | var validEnv = ['development', 'test', 'production'] 3 | var currentEnv = api.env() 4 | var isDevelopmentEnv = api.env('development') 5 | var isProductionEnv = api.env('production') 6 | var isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | isTestEnv && [ 21 | '@babel/preset-env', 22 | { 23 | targets: { 24 | node: 'current' 25 | } 26 | } 27 | ], 28 | (isProductionEnv || isDevelopmentEnv) && [ 29 | '@babel/preset-env', 30 | { 31 | forceAllTransforms: true, 32 | useBuiltIns: 'entry', 33 | corejs: 3, 34 | modules: false, 35 | exclude: ['transform-typeof-symbol'] 36 | } 37 | ], 38 | '@babel/preset-react', 39 | ].filter(Boolean), 40 | plugins: [ 41 | 'babel-plugin-macros', 42 | '@babel/plugin-syntax-dynamic-import', 43 | isTestEnv && 'babel-plugin-dynamic-import-node', 44 | '@babel/plugin-transform-destructuring', 45 | '@babel/plugin-transform-react-jsx', 46 | [ 47 | '@babel/plugin-proposal-class-properties', 48 | { 49 | loose: true 50 | } 51 | ], 52 | [ 53 | '@babel/plugin-proposal-object-rest-spread', 54 | { 55 | useBuiltIns: true 56 | } 57 | ], 58 | [ 59 | '@babel/plugin-transform-runtime', 60 | { 61 | helpers: false 62 | } 63 | ], 64 | [ 65 | '@babel/plugin-transform-regenerator', 66 | { 67 | async: false 68 | } 69 | ], 70 | ].filter(Boolean) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /config/initializers/sidekiq.rb: -------------------------------------------------------------------------------- 1 | Sidekiq.configure_client do |config| 2 | config.redis = { url: ENV['REDIS_URL'], ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE } } 3 | end 4 | 5 | Sidekiq.configure_server do |config| 6 | config.redis = { url: ENV['REDIS_URL'], ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE } } 7 | end 8 | 9 | Sidekiq::Options[:cron_poll_interval] = 2 10 | 11 | Rails.application.reloader.to_prepare do 12 | Sidekiq::Cron::Job.create(name: 'RoutingRefreshWorker - Every 30 secs', cron: '*/30 * * * * *', class: 'RoutingRefreshWorker') 13 | Sidekiq::Cron::Job.create(name: 'FeedRetrieverSpawningAWorker - Every 15 secs', cron: '0-46/15 * * * * *', class: 'FeedRetrieverSpawningAWorker') 14 | Sidekiq::Cron::Job.create(name: 'FeedRetrieverSpawningB1Worker - Every 15 secs', cron: '5-51/15 * * * * *', class: 'FeedRetrieverSpawningB1Worker') 15 | Sidekiq::Cron::Job.create(name: 'FeedRetrieverSpawningB2Worker - Every 15 secs', cron: '10-59/15 * * * * *', class: 'FeedRetrieverSpawningB2Worker') 16 | Sidekiq::Cron::Job.create(name: 'AccessibilityListWorker - Every 5 mins', cron: '*/5 * * * *', class: 'AccessibilityListWorker') 17 | Sidekiq::Cron::Job.create(name: 'AccessibilityStatusesWorker - Every 5 mins', cron: '*/5 * * * *', class: 'AccessibilityStatusesWorker') 18 | Sidekiq::Cron::Job.create(name: 'TwitterDelaysNotifierWorker - Every 1 min', cron: '* * * * *', class: 'TwitterDelaysNotifierWorker') 19 | Sidekiq::Cron::Job.create(name: 'TwitterServiceChangesNotifierWorker - Every 1 min', cron: '* * * * *', class: 'TwitterServiceChangesNotifierWorker') 20 | Sidekiq::Cron::Job.create(name: 'HerokuAutoscalerWorker - Every 1 min', cron: '* * * * *', class: 'HerokuAutoscalerWorker') 21 | Sidekiq::Cron::Job.create(name: 'TravelTimesRefreshWorker - Every 2 min', cron: '*/2 * * * *', class: 'TravelTimesRefreshWorker') 22 | Sidekiq::Cron::Job.create(name: 'ScheduledTimesRefreshWorker - Every 5 min', cron: '*/5 * * * *', class: 'ScheduledTimesRefreshWorker') 23 | Sidekiq::Cron::Job.create(name: 'RedisCleanupWorker - Every 30 mins', cron: '*/30 * * * *', class: 'RedisCleanupWorker') 24 | end -------------------------------------------------------------------------------- /app/helpers/twitter_helper.rb: -------------------------------------------------------------------------------- 1 | module TwitterHelper 2 | TWITTER_MAX_CHARS = 280 - 10 3 | ROUTE_CLIENT_MAPPING = (ENV['TWITTER_ROUTE_CLIENT_MAPPING'] || '').split(",").to_h { |str| 4 | array = str.split(":") 5 | [array.first, array.second] 6 | } 7 | ENABLE_ROUTE_CLIENTS = ENV['TWITTER_ENABLE_ROUTE_CLIENTS'] ? ActiveModel::Type::Boolean.new.cast(ENV['TWITTER_ENABLE_ROUTE_CLIENTS']) : true 8 | 9 | def route_names(route_ids) 10 | route_ids.map { |r| 11 | route = Scheduled::Route.find_by(internal_id: r) 12 | if route.alternate_name 13 | "#{route.name} - #{route.alternate_name}" 14 | else 15 | route.name 16 | end 17 | }.sort.join(', ') 18 | end 19 | 20 | def tweet_url(tweet_id, route_id) 21 | if route_id == 'all' 22 | twitter_account = ENV['TWITTER_USERNAME'] || "goodservicetest" 23 | "https://twitter.com/#{twitter_account}/status/#{tweet_id}" 24 | else 25 | twitter_account_name_prefix = ENV['TWITTER_USERNAME_ROUTE_CLIENT_PREFIX'] || "goodservice_" 26 | "https://twitter.com/#{twitter_account_name_prefix}#{route_id}/status/#{tweet_id}" 27 | end 28 | end 29 | 30 | def twitter_client 31 | return unless ENV["TWITTER_CONSUMER_KEY"] 32 | @twitter_client ||= Twitter::REST::Client.new do |config| 33 | config.consumer_key = ENV["TWITTER_CONSUMER_KEY"] 34 | config.consumer_secret = ENV["TWITTER_CONSUMER_SECRET"] 35 | config.access_token = ENV["TWITTER_ACCESS_TOKEN"] 36 | config.access_token_secret = ENV["TWITTER_ACCESS_TOKEN_SECRET"] 37 | end 38 | end 39 | 40 | def twitter_route_client(route_id) 41 | return unless ENABLE_ROUTE_CLIENTS && ENV["TWITTER_CONSUMER_KEY"] && ENV["TWITTER_CLIENT_#{route_id}_ACCESS_TOKEN"] 42 | Twitter::REST::Client.new do |config| 43 | config.consumer_key = ENV["TWITTER_CONSUMER_KEY"] 44 | config.consumer_secret = ENV["TWITTER_CONSUMER_SECRET"] 45 | config.access_token = ENV["TWITTER_CLIENT_#{route_id}_ACCESS_TOKEN"] 46 | config.access_token_secret = ENV["TWITTER_CLIENT_#{route_id}_ACCESS_TOKEN_SECRET"] 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /app/javascript/packs/components/train.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Segment, Header, Button, Responsive } from "semantic-ui-react"; 3 | import { withRouter } from 'react-router-dom'; 4 | 5 | import TrainBullet from './trainBullet'; 6 | import TrainModal from './trainModal'; 7 | import { statusColor } from './utils'; 8 | 9 | import './train.scss'; 10 | 11 | class Train extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | this.state = { 15 | modelOpen: false 16 | } 17 | } 18 | 19 | alternateName() { 20 | const alternameName = this.props.train.alternate_name; 21 | if (alternameName) { 22 | const alt = alternameName.replace("Shuttle", ""); 23 | return ( 24 | {alt} 25 | ) 26 | } 27 | } 28 | 29 | handleClick = () => { 30 | const { history, train } = this.props 31 | history.push('/trains/' + train.id); 32 | } 33 | 34 | renderBullet() { 35 | const { train } = this.props; 36 | return ( 37 | 38 | 40 |
{this.alternateName() || '\xa0'}
41 |
42 | ); 43 | } 44 | 45 | renderInfo() { 46 | const { status } = this.props.train; 47 | return ( 48 |
{ status }
49 | ) 50 | } 51 | 52 | render() { 53 | const { train, trains, selected, stations } = this.props; 54 | return( 55 | 56 | { 57 | selected && 58 | 59 | } 60 | 64 | 65 | ) 66 | } 67 | } 68 | export default withRouter(Train); -------------------------------------------------------------------------------- /app/workers/heroku_autoscaler_worker.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/api' 2 | 3 | class HerokuAutoscalerWorker 4 | include Sidekiq::Worker 5 | sidekiq_options retry: false, queue: 'critical' 6 | 7 | MINIMUM_NUMBER_OF_DYNOS = ENV['AUTOSCALER_MIN_DYNOS']&.to_i || 1 8 | MAXIMUM_NUMBER_OF_DYNOS = ENV['AUTOSCALER_MAX_DYNOS']&.to_i || 5 9 | SCALEUP_DELAY = 2.minutes.to_i 10 | SCALE_DOWN_THRESHOLD = ENV['AUTOSCALER_SCALE_DOWN_THRESHOLD']&.to_i || 0 11 | 12 | def perform 13 | return unless ENV['HEROKU_OAUTH_TOKEN'] && ENV['HEROKU_APP_NAME'] 14 | heroku = PlatformAPI.connect_oauth(ENV['HEROKU_OAUTH_TOKEN']) 15 | info = heroku.formation.info(ENV['HEROKU_APP_NAME'], 'worker') 16 | return unless info 17 | 18 | number_of_dynos = info['quantity'] 19 | queue_latency = Sidekiq::Queue.new.latency 20 | jobs_in_queue = Sidekiq::Queue.new.size 21 | puts "HerokuAutoscaler: #{number_of_dynos} dynos, queue latency #{queue_latency}, #{jobs_in_queue} jobs" 22 | 23 | if jobs_in_queue <= SCALE_DOWN_THRESHOLD 24 | last_unempty_timestamp = RedisStore.last_unempty_workqueue_timestamp 25 | if last_unempty_timestamp && (Time.current - Time.zone.at(last_unempty_timestamp) >= 10.minutes) && number_of_dynos > MINIMUM_NUMBER_OF_DYNOS 26 | new_quantity = number_of_dynos - 1 27 | heroku.formation.update(ENV['HEROKU_APP_NAME'], 'worker', {"quantity" => new_quantity}) 28 | puts "HerokuAutoscaler: Scaled down to #{new_quantity} dynos" 29 | 30 | # Reset counter 31 | RedisStore.update_last_unempty_workqueue_timestamp 32 | end 33 | else 34 | if queue_latency > 15 35 | if number_of_dynos < MAXIMUM_NUMBER_OF_DYNOS && (!RedisStore.last_scaleup_timestamp || RedisStore.last_scaleup_timestamp < Time.current.to_i - SCALEUP_DELAY) 36 | new_quantity = number_of_dynos + 1 37 | heroku.formation.update(ENV['HEROKU_APP_NAME'], 'worker', {"quantity" => new_quantity}) 38 | puts "HerokuAutoscaler: Scaled up to #{new_quantity} dynos" 39 | RedisStore.update_last_scaleup_timestamp 40 | end 41 | end 42 | RedisStore.update_last_unempty_workqueue_timestamp 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /config/webpacker.yml: -------------------------------------------------------------------------------- 1 | # Note: You must restart bin/webpack-dev-server for changes to take effect 2 | 3 | default: &default 4 | source_path: app/javascript 5 | source_entry_path: packs 6 | public_root_path: public 7 | public_output_path: packs 8 | cache_path: tmp/cache/webpacker 9 | webpack_compile_output: true 10 | 11 | # Additional paths webpack should lookup modules 12 | # ['app/assets', 'engine/foo/app/assets'] 13 | additional_paths: ['app/assets'] 14 | 15 | # Reload manifest.json on all requests so we reload latest compiled packs 16 | cache_manifest: false 17 | 18 | # Extract and emit a css file 19 | extract_css: true 20 | 21 | static_assets_extensions: 22 | - .jpg 23 | - .jpeg 24 | - .png 25 | - .gif 26 | - .tiff 27 | - .ico 28 | - .svg 29 | - .eot 30 | - .otf 31 | - .ttf 32 | - .woff 33 | - .woff2 34 | - .webmanifest 35 | 36 | extensions: 37 | - .mjs 38 | - .js 39 | - .jsx 40 | - .sass 41 | - .scss 42 | - .css 43 | - .module.sass 44 | - .module.scss 45 | - .module.css 46 | - .png 47 | - .svg 48 | - .gif 49 | - .jpeg 50 | - .jpg 51 | 52 | development: 53 | <<: *default 54 | compile: true 55 | 56 | # Reference: https://webpack.js.org/configuration/dev-server/ 57 | dev_server: 58 | https: false 59 | host: localhost 60 | port: 3035 61 | public: localhost:3035 62 | hmr: false 63 | # Inline should be set to true if using HMR 64 | inline: true 65 | overlay: true 66 | compress: true 67 | disable_host_check: true 68 | use_local_ip: false 69 | quiet: false 70 | pretty: false 71 | headers: 72 | 'Access-Control-Allow-Origin': '*' 73 | watch_options: 74 | ignored: '**/node_modules/**' 75 | 76 | 77 | test: 78 | <<: *default 79 | compile: true 80 | 81 | # Compile test packs to a separate directory 82 | public_output_path: packs-test 83 | 84 | production: 85 | <<: *default 86 | 87 | # Production depends on precompilation of packs prior to booting for performance. 88 | compile: false 89 | 90 | # Extract and emit a css file 91 | extract_css: true 92 | 93 | # Cache manifest.json for performance 94 | cache_manifest: true 95 | -------------------------------------------------------------------------------- /app/models/long_term_service_change_regular_routing.rb: -------------------------------------------------------------------------------- 1 | class LongTermServiceChangeRegularRouting 2 | attr_accessor :route_id, :direction, :first_departure, :last_run_times, :is_weekday, :is_saturday, :is_sunday 3 | 4 | def initialize(route_id, direction, first_departure, last_run_times, is_weekday = true, is_saturday = true, is_sunday = true) 5 | self.route_id = route_id 6 | self.direction = direction 7 | self.first_departure = convert_time_to_timestamp(first_departure) 8 | self.last_run_times = last_run_times.to_h { |k, v| [k, convert_time_to_timestamp(v)] } 9 | self.is_weekday = is_weekday 10 | self.is_saturday = is_saturday 11 | self.is_sunday = is_sunday 12 | end 13 | 14 | def self.all_times(route_id, direction, routing) 15 | LongTermServiceChangeRegularRouting.new(route_id, direction, nil, routing.to_h { |s| [s, nil] }) 16 | end 17 | 18 | def is_all_times? 19 | first_departure.nil? 20 | end 21 | 22 | def is_effective_until_end_of_day? 23 | last_run_times.values.all? { |t| t.nil? } 24 | end 25 | 26 | def routing 27 | last_run_times.keys 28 | end 29 | 30 | def get_applicable_routing(timestamp, current_day_of_week, prev_day_of_week) 31 | todays_routing = get_day_routing(timestamp, current_day_of_week) 32 | 33 | return [todays_routing].compact if is_effective_until_end_of_day? 34 | 35 | [ 36 | todays_routing, 37 | get_day_routing(timestamp + 1.day.to_i, prev_day_of_week) 38 | ].uniq.compact 39 | end 40 | 41 | private 42 | 43 | def convert_time_to_timestamp(time) 44 | return nil unless time 45 | array = time.split(':').map(&:to_i) 46 | array[0] * 3600 + array[1] * 60 + array[2] 47 | end 48 | 49 | def get_day_routing(timestamp, day_of_week) 50 | return nil unless is_day_valid?(day_of_week) 51 | return nil unless is_all_times? || timestamp + 30.minutes.to_i >= first_departure 52 | 53 | last_run_times.filter { |_, v| v.nil? || timestamp <= v }.map { |k, _| k }.presence 54 | end 55 | 56 | def is_day_valid?(day_of_week) 57 | return true if is_all_times? 58 | case day_of_week 59 | when "Saturday" 60 | return false unless is_saturday 61 | when "Sunday" 62 | return false unless is_sunday 63 | else 64 | return false unless is_weekday 65 | end 66 | 67 | true 68 | end 69 | end -------------------------------------------------------------------------------- /app/models/scheduled/trip.rb: -------------------------------------------------------------------------------- 1 | class Scheduled::Trip < ActiveRecord::Base 2 | belongs_to :route, foreign_key: "route_internal_id", primary_key: "internal_id" 3 | belongs_to :schedule, foreign_key: "schedule_service_id", primary_key: "service_id" 4 | has_many :stop_times, -> { order("departure_time") }, foreign_key: "trip_internal_id", primary_key: "internal_id" 5 | 6 | DAY_IN_MINUTES = 86400 7 | 8 | def self.soon(current_timestamp, route_id, time_range: 30.minutes) 9 | current_time = Time.zone.at(current_timestamp) 10 | from_time = current_time - current_time.beginning_of_day 11 | to_time = current_time - current_time.beginning_of_day + time_range.to_i 12 | additional_departure_time_range = from_time..to_time 13 | additional_filters = route_id ? { route_internal_id: route_id} : {} 14 | 15 | if (current_time + time_range).to_date == current_time.to_date.tomorrow 16 | next_day_to_time = (current_time - current_time.beginning_of_day + time_range.to_i) % DAY_IN_MINUTES 17 | 18 | additional_departure_time_range = 0..next_day_to_time 19 | elsif current_time.hour < 4 20 | twenty_four_hr = current_time - current_time.beginning_of_day + DAY_IN_MINUTES 21 | time_after_twenty_four_hr = current_time - current_time.beginning_of_day + DAY_IN_MINUTES + time_range.to_i 22 | 23 | additional_departure_time_range = twenty_four_hr..time_after_twenty_four_hr 24 | end 25 | 26 | includes(:stop_times, :route).where( 27 | { 28 | stop_times: { 29 | departure_time: from_time..to_time, 30 | }, 31 | }.merge(additional_filters) 32 | ).or( 33 | includes(:stop_times, :route).where( 34 | { 35 | stop_times: { 36 | departure_time: additional_departure_time_range, 37 | } 38 | }.merge(additional_filters) 39 | ) 40 | ).joins(:schedule).merge(Scheduled::Schedule.today(date: current_time.to_date)) 41 | end 42 | 43 | def self.soon_grouped(current_timestamp, route_id, time_range: 30.minutes) 44 | results = soon(current_timestamp, route_id, time_range: time_range) 45 | 46 | if route_id 47 | results.group_by(&:direction) 48 | else 49 | results.group_by(&:route_internal_id).map { |route_id, trips| 50 | [route_id, trips.group_by(&:direction)] 51 | }.to_h 52 | end 53 | end 54 | end -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | config.cache_classes = false 12 | config.action_view.cache_template_loading = true 13 | 14 | # Do not eager load code on boot. This avoids loading your whole application 15 | # just for the purpose of running a single test. If you are using a tool that 16 | # preloads Rails for running tests, you may have to set it to true. 17 | config.eager_load = false 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /app/models/delay_notification.rb: -------------------------------------------------------------------------------- 1 | class DelayNotification 2 | attr_accessor :routes, :direction, :stops, :affected_sections, :destinations, :last_tweet_ids, :last_tweet_times, :mins_since_observed, :tracks 3 | 4 | def initialize(route, direction, stops, routing, destinations, tracks = {}) 5 | affected_section_indices = stops.map {|s| routing.index(s) } 6 | affected_section = routing[affected_section_indices.min..affected_section_indices.max] 7 | 8 | @routes = [route] 9 | @direction = direction 10 | @stops = stops 11 | @affected_sections = [affected_section] 12 | @destinations = destinations.uniq 13 | @mins_since_observed = 0 14 | @last_tweet_ids = {} 15 | @last_tweet_times = {} 16 | @tracks = tracks.slice(stops) 17 | end 18 | 19 | def append!(route, new_stops, routing, new_destinations, new_tracks) 20 | @routes = (routes + [route]).uniq.sort 21 | @destinations = (destinations + new_destinations).uniq 22 | matched_section = affected_sections.find { |section| 23 | routing.include?(section.first) && routing.include?(section.last) 24 | } 25 | @tracks = tracks.merge(new_tracks.slice(*new_stops)) 26 | 27 | p "Routes: #{routes}" 28 | p "Existing sections: #{affected_sections}" 29 | p "New section: #{new_stops}" 30 | p "Matched section: #{matched_section}" 31 | 32 | if matched_section 33 | indices = [matched_section.first, matched_section.last, new_stops.first, new_stops.last].map {|s| routing.index(s) } 34 | @affected_sections.delete(matched_section) 35 | @affected_sections << routing[indices.min..indices.max] 36 | else 37 | indices = [stops.first, stops.last, new_stops.first, new_stops.last].map {|s| routing.index(s) } 38 | @affected_sections = [] unless affected_sections 39 | @affected_sections << routing[indices.min..indices.max] 40 | end 41 | p "Updated affected sections: #{affected_sections}" 42 | end 43 | 44 | def match_routing?(routing, potential_matched_stops, potential_stop_tracks) 45 | return false unless routing.each_cons(stops.size).any? { |arr| arr == stops && arr.all? { |s| !tracks[s] || !potential_stop_tracks[s] || tracks[s] == potential_stop_tracks[s] }} 46 | return true if stops.any? { |s| potential_matched_stops.include?(s) && (!tracks[s] || !potential_stop_tracks[s] || tracks[s] == potential_stop_tracks[s])} 47 | 48 | stop_indices = [stops.first, stops.last].map {|s| routing.index(s) } 49 | potential_stop_indices = [potential_matched_stops.first, potential_matched_stops.last].map {|s| routing.index(s) } 50 | diffs = stop_indices.flat_map { |s| potential_stop_indices.map {|ps| (ps - s).abs }} 51 | diffs.min <= 5 52 | end 53 | 54 | def update_not_observed! 55 | @mins_since_observed += 1 56 | end 57 | end -------------------------------------------------------------------------------- /app/javascript/packs/components/trainBullet.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Segment, Header } from "semantic-ui-react"; 3 | import { Link } from "react-router-dom"; 4 | 5 | import './trainBullet.scss'; 6 | 7 | class TrainBullet extends React.Component { 8 | name() { 9 | const name = this.props.name; 10 | return name.endsWith("X") ? name[0] : name; 11 | } 12 | 13 | classNames() { 14 | const { size, name, directions } = this.props; 15 | const directionClass = (directions && directions.length === 1) ? (directions[0] === 'north' ? 'uptown-only' : 'downtown-only') : '' 16 | const fontStretchClass = name.length > 2 ? ' condensed' : ''; 17 | if (size === 'small') { 18 | return name.endsWith("X") ? 'small route diamond' : 'small route bullet ' + directionClass + fontStretchClass; 19 | } else if (size === 'medium') { 20 | return name.endsWith("X") ? 'medium route diamond' : 'medium route bullet ' + directionClass + fontStretchClass; 21 | } 22 | return name.endsWith("X") ? 'route diamond' : 'route bullet' + directionClass + fontStretchClass; 23 | } 24 | 25 | innerClassNames() { 26 | return this.props.name.endsWith("X") ? 'diamond-inner' : '' 27 | } 28 | 29 | style() { 30 | const { style, color, textColor, size, name, alternateName } = this.props; 31 | let nameLength = name.length + (alternateName?.length || 0); 32 | let styleHash = { 33 | ...style, 34 | backgroundColor: `${color}` 35 | }; 36 | 37 | if (textColor) { 38 | styleHash.color = `${textColor}`; 39 | } 40 | 41 | if (size === 'small' && nameLength > 2) { 42 | styleHash.letterSpacing = '-.06em'; 43 | } 44 | 45 | return styleHash; 46 | } 47 | 48 | innerStyle() { 49 | const { name, directions, color, textColor, size, alternateName } = this.props; 50 | let nameLength = name.length + (alternateName?.length || 0); 51 | if (!name.endsWith("X") && directions && directions.length === 1 && textColor && textColor.toUpperCase() !== '#FFFFFF') { 52 | return { WebkitTextStroke: `0.5px ${color}` } 53 | } 54 | if (size === 'small' && nameLength > 2) { 55 | return { fontSize: '.9em' }; 56 | } 57 | } 58 | 59 | render() { 60 | const { link, id, linkedView, alternateName } = this.props; 61 | const view = linkedView && '/' + linkedView || "" 62 | if (link) { 63 | return( 64 | 65 |
66 |
{this.name()}{alternateName}
67 |
68 | 69 | ) 70 | } else { 71 | return( 72 |
73 |
{this.name()}{alternateName}
74 |
75 | ) 76 | } 77 | } 78 | } 79 | export default TrainBullet -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby File.read(".ruby-version").strip 5 | 6 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 7 | gem 'rails', '~> 7.1.0' 8 | 9 | gem 'pg' 10 | 11 | # Use Puma as the app server 12 | gem 'puma', '< 7' 13 | # Use SCSS for stylesheets 14 | gem 'sass-rails', '>= 6' 15 | # Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker 16 | gem 'webpacker', '~> 5.0' 17 | gem 'webpacker-react', "~> 1.0.0.beta.1" 18 | # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks 19 | gem 'turbolinks', '~> 5' 20 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 21 | gem 'jbuilder', '~> 2.7' 22 | # Use Redis adapter to run Action Cable in production 23 | gem 'redis', '~> 4.0' 24 | gem 'hiredis' 25 | # Use Active Model has_secure_password 26 | # gem 'bcrypt', '~> 3.1.7' 27 | 28 | # Use Active Storage variant 29 | # gem 'image_processing', '~> 1.2' 30 | 31 | # Reduces boot times through caching; required in config/boot.rb 32 | gem 'bootsnap', '>= 1.9.4', require: false 33 | 34 | gem 'gtfs-realtime-bindings' 35 | gem 'parallel' 36 | gem 'sidekiq' 37 | gem "sidekiq-cron", "~> 1.12" 38 | gem 'foreman' 39 | gem 'platform-api' 40 | gem 'barnes' 41 | gem 'naturally' 42 | gem 'twitter' 43 | gem 'rack-cors' 44 | gem 'httparty' 45 | gem 'psych', '< 4' 46 | gem 'rack', '~> 2.2.4' 47 | 48 | # Needed since Ruby 3.x upgrade 49 | gem 'rss' 50 | gem 'net-smtp' # to send email 51 | gem 'net-imap' # for rspec 52 | gem 'net-pop' # for rspec 53 | 54 | # gem 'rack-cors' 55 | # gem 'twitter' 56 | # gem 'snitcher' 57 | # gem 'staccato' 58 | # gem 'hashdiff' 59 | # gem 'descriptive_statistics' 60 | gem 'htmlentities' 61 | 62 | group :development, :test do 63 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 64 | gem 'pry-byebug', platforms: [:mri, :mingw, :x64_mingw] 65 | end 66 | 67 | group :development do 68 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code. 69 | gem 'web-console', '>= 4.1.0' 70 | # Display performance information such as SQL time and flame graphs for each request in your browser. 71 | # Can be configured to work on production as well see: https://github.com/MiniProfiler/rack-mini-profiler/blob/master/README.md 72 | gem 'rack-mini-profiler', '~> 2.0' 73 | gem 'listen', '~> 3.5' 74 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 75 | gem 'spring' 76 | end 77 | 78 | group :test do 79 | # Adds support for Capybara system testing and selenium driver 80 | gem 'capybara', '>= 3.26' 81 | gem 'selenium-webdriver' 82 | # Easy installation and use of web drivers to run system tests with browsers 83 | gem 'webdrivers' 84 | end 85 | 86 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 87 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] 88 | 89 | gem "webrick", "~> 1.8" 90 | -------------------------------------------------------------------------------- /app/javascript/packs/components/utils.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Header, Icon } from "semantic-ui-react"; 3 | 4 | import TrainBullet from './trainBullet'; 5 | 6 | const TWITTER_FEEDS_EXCLUDED_TRAINS = ['FS', 'GS', 'SI']; 7 | const TWITTER_FEEDS_MAPPED_TRAINS = { 8 | '6X': '6', 9 | '7X': '7', 10 | 'FX': 'F', 11 | 'H': 'A', 12 | } 13 | 14 | export const statusColor = (status) => { 15 | if (status == 'Good Service') { 16 | return 'green'; 17 | } else if (status == 'Service Change') { 18 | return 'orange'; 19 | } else if (status == 'Not Good') { 20 | return 'yellow'; 21 | } else if (status == 'Slow') { 22 | return 'yellow'; 23 | } else if (status == 'Delay') { 24 | return 'red'; 25 | } 26 | }; 27 | 28 | export const formatStation = (stationName) => { 29 | if (!stationName) { 30 | return; 31 | } 32 | return stationName.replace(/ - /g, "–") 33 | }; 34 | 35 | export const replaceTrainBulletsInParagraphs = (trains, array_of_strs) => { 36 | return array_of_strs.map((change, i) => { 37 | let tmp = [formatStation(change)]; 38 | let matched; 39 | while (matched = tmp.find((c) => typeof c === 'string' && c.match(/\<[A-Z0-9]*\>/))) { 40 | const regexResult = matched.match(/\<([A-Z0-9]*)\>/); 41 | let j = tmp.indexOf(matched); 42 | const selectedTrain = trains[regexResult[1]]; 43 | const selectedTrainBullet = (); 45 | const parts = matched.split(regexResult[0]); 46 | let newMatched = parts.flatMap((x) => [x, selectedTrainBullet]); 47 | newMatched.pop(); 48 | tmp[j] = newMatched; 49 | tmp = tmp.flat(); 50 | } 51 | 52 | return (
{tmp}
); 53 | }); 54 | }; 55 | 56 | export const formatMinutes = (minutes, markDue, prefixPositiveValues) => { 57 | if (minutes > 0) { 58 | if (prefixPositiveValues) { 59 | return `+${minutes} min`; 60 | } 61 | return `${minutes} min`; 62 | } 63 | if (markDue) { 64 | return 'Due'; 65 | } 66 | return `${minutes} min`; 67 | }; 68 | 69 | export const routingHash = (routing) => { 70 | return `${routing[0]}-${routing[routing.length-1]}-${routing.length}`; 71 | } 72 | 73 | export const twitterLink = (trainId) => { 74 | if (TWITTER_FEEDS_EXCLUDED_TRAINS.includes(trainId)) { 75 | return; 76 | } 77 | const twitterTrainId = TWITTER_FEEDS_MAPPED_TRAINS[trainId] || trainId; 78 | return ( 79 |
80 | 81 | Follow @goodservice_{twitterTrainId} 82 | 83 | 84 |
85 | ); 86 | }; 87 | 88 | export const hexToRgb = (hex) => { 89 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 90 | return result ? { 91 | r: parseInt(result[1], 16), 92 | g: parseInt(result[2], 16), 93 | b: parseInt(result[3], 16) 94 | } : null; 95 | } 96 | -------------------------------------------------------------------------------- /app/javascript/packs/vendor/smartbanner.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * smartbanner.js v1.24.1 3 | * Copyright © 2024 Ain Tohvri, contributors. Licensed under GPL-3.0. 4 | */ 5 | .smartbanner{position:absolute;top:0;left:0;overflow-x:hidden;width:100%;height:84px;background:#f3f3f3;font-family:Helvetica,sans,sans-serif}.smartbanner__exit{position:absolute;top:calc(50% - 6px);left:9px;display:block;margin:0;width:12px;height:12px;border:0;text-align:center}.smartbanner__exit::before,.smartbanner__exit::after{position:absolute;width:1px;height:12px;background:#716f6f;content:" "}.smartbanner__exit::before{transform:rotate(45deg)}.smartbanner__exit::after{transform:rotate(-45deg)}.smartbanner__icon{position:absolute;top:10px;left:30px;width:64px;height:64px;border-radius:15px;background-size:64px 64px}.smartbanner__info{position:absolute;top:10px;left:104px;display:flex;overflow-y:hidden;width:60%;height:64px;align-items:center;color:#000}.smartbanner__info__title{font-size:14px}.smartbanner__info__author,.smartbanner__info__price{font-size:12px}.smartbanner__button{position:absolute;top:32px;right:10px;z-index:1;display:block;padding:0 10px;min-width:10%;border-radius:5px;background:#f3f3f3;color:#1474fc;font-size:18px;text-align:center;text-decoration:none}.smartbanner__button__label{text-align:center}.smartbanner.smartbanner--android{background:#3d3d3d url("data:image/gif;base64,R0lGODlhCAAIAIABAFVVVf///yH5BAEHAAEALAAAAAAIAAgAAAINRG4XudroGJBRsYcxKAA7");box-shadow:inset 0 4px 0 #88b131}.smartbanner.smartbanner--android .smartbanner__exit{left:6px;margin-right:7px;width:17px;height:17px;border-radius:14px;background:#1c1e21;box-shadow:0 1px 2px rgba(0,0,0,.8) inset,0 1px 1px rgba(255,255,255,.3);color:#b1b1b3;font-family:"ArialRoundedMTBold",Arial;font-size:20px;line-height:17px;text-shadow:0 1px 1px #000}.smartbanner.smartbanner--android .smartbanner__exit::before,.smartbanner.smartbanner--android .smartbanner__exit::after{top:3px;left:8px;width:2px;height:11px;background:#b1b1b3}.smartbanner.smartbanner--android .smartbanner__exit:active,.smartbanner.smartbanner--android .smartbanner__exit:hover{color:#eee}.smartbanner.smartbanner--android .smartbanner__icon{background-color:rgba(0,0,0,0);box-shadow:none}.smartbanner.smartbanner--android .smartbanner__info{color:#ccc;text-shadow:0 1px 2px #000}.smartbanner.smartbanner--android .smartbanner__info__title{color:#fff;font-weight:bold}.smartbanner.smartbanner--android .smartbanner__button{top:30px;right:20px;padding:0;min-width:12%;border-radius:0;background:none;box-shadow:0 0 0 1px #333,0 0 0 2px #dddcdc;color:#d1d1d1;font-size:14px;font-weight:bold}.smartbanner.smartbanner--android .smartbanner__button:active,.smartbanner.smartbanner--android .smartbanner__button:hover{background:none}.smartbanner.smartbanner--android .smartbanner__button__label{display:block;padding:0 10px;background:#42b6c9;background:linear-gradient(to bottom, #42b6c9, #39a9bb);box-shadow:none;line-height:24px;text-align:center;text-shadow:none;text-transform:none}.smartbanner.smartbanner--android .smartbanner__button__label:active,.smartbanner.smartbanner--android .smartbanner__button__label:hover{background:#2ac7e1}/*# sourceMappingURL=smartbanner.min.css.map */ 6 | -------------------------------------------------------------------------------- /app/views/slack/index.html.erb: -------------------------------------------------------------------------------- 1 | <% title "Slack" %> 2 | 3 |
4 |

goodservice.io for Slack

5 | 6 | 7 |
8 |

The same data that drives goodservice.io, now available on Slack!

9 |
10 |
11 | <%= image_tag('slack.gif', class: "screenshot") %> 12 |
13 |
14 | 15 | Add to Slack 16 | 17 |
18 |
19 | 24 |
25 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable/disable caching. By default caching is disabled. 18 | # Run rails dev:cache to toggle caching. 19 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 20 | config.action_controller.perform_caching = true 21 | config.action_controller.enable_fragment_cache_logging = true 22 | 23 | config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] } 24 | config.public_file_server.headers = { 25 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 26 | } 27 | else 28 | config.action_controller.perform_caching = false 29 | 30 | config.cache_store = :null_store 31 | end 32 | 33 | # Store uploaded files on the local file system (see config/storage.yml for options). 34 | config.active_storage.service = :local 35 | 36 | # Don't care if the mailer can't send. 37 | config.action_mailer.raise_delivery_errors = false 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Print deprecation notices to the Rails logger. 42 | config.active_support.deprecation = :log 43 | 44 | # Raise exceptions for disallowed deprecations. 45 | config.active_support.disallowed_deprecation = :raise 46 | 47 | # Tell Active Support which deprecation messages to disallow. 48 | config.active_support.disallowed_deprecation_warnings = [] 49 | 50 | # Raise an error on page load if there are pending migrations. 51 | config.active_record.migration_error = :page_load 52 | 53 | # Highlight code that triggered database queries in logs. 54 | config.active_record.verbose_query_logs = true 55 | 56 | # Debug mode disables concatenation and preprocessing of assets. 57 | # This option may cause significant delays in view rendering with a large 58 | # number of complex assets. 59 | config.assets.debug = true 60 | config.assets.check_precompiled_asset = true 61 | 62 | # Suppress logger output for asset requests. 63 | config.assets.quiet = true 64 | 65 | # Raises error for missing translations. 66 | # config.i18n.raise_on_missing_translations = true 67 | 68 | # Annotate rendered view with file names. 69 | # config.action_view.annotate_rendered_view_with_filenames = true 70 | 71 | # Use an evented file watcher to asynchronously detect changes in source code, 72 | # routes, locales, etc. This feature depends on the listen gem. 73 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 74 | 75 | # Uncomment if you wish to allow Action Cable access from any origin. 76 | # config.action_cable.disable_request_forgery_protection = true 77 | 78 | config.hosts << /[a-z0-9\-]+\.ngrok\.io/ 79 | end 80 | -------------------------------------------------------------------------------- /db/migrate/20210102223417_initialize_scheduled_models.rb: -------------------------------------------------------------------------------- 1 | class InitializeScheduledModels < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :routes do |t| 4 | t.string :internal_id, null: false 5 | t.string :name, null: false 6 | t.string :alternate_name 7 | t.string :color, null: false 8 | t.string :text_color 9 | t.boolean :visible, null: false, default: true 10 | end 11 | 12 | add_index :routes, :internal_id, unique: true 13 | 14 | create_table :schedules do |t| 15 | t.string :service_id, null: false 16 | t.integer :monday, null: false 17 | t.integer :tuesday, null: false 18 | t.integer :wednesday, null: false 19 | t.integer :thursday, null: false 20 | t.integer :friday, null: false 21 | t.integer :saturday, null: false 22 | t.integer :sunday, null: false 23 | t.date :start_date, null: false 24 | t.date :end_date, null: false 25 | end 26 | 27 | add_index :schedules, :service_id, unique: true 28 | 29 | create_table :calendar_exceptions do |t| 30 | t.string :schedule_service_id, null: false 31 | t.date :date, null: false 32 | t.integer :exception_type, null: false 33 | end 34 | 35 | add_foreign_key :calendar_exceptions, :schedules, column: :schedule_service_id, primary_key: :service_id 36 | 37 | create_table :stops do |t| 38 | t.string :internal_id, null: false 39 | t.string :stop_name, null: false 40 | t.string :parent_stop_id 41 | end 42 | 43 | add_index :stops, :internal_id, unique: true 44 | 45 | create_table :trips do |t| 46 | t.string :internal_id, null: false 47 | t.string :route_internal_id, null: false 48 | t.string :schedule_service_id, null: false 49 | t.string :destination, null: false 50 | t.integer :direction, null: false 51 | end 52 | 53 | add_foreign_key :trips, :routes, column: :route_internal_id, primary_key: :internal_id 54 | add_foreign_key :trips, :schedules, column: :schedule_service_id, primary_key: :service_id 55 | add_index :trips, :internal_id, unique: true 56 | 57 | create_table :stop_times do |t| 58 | t.string :trip_internal_id, null: false 59 | t.integer :departure_time, null: false 60 | t.string :stop_internal_id, null: false 61 | t.integer :stop_sequence, null: false 62 | end 63 | 64 | add_foreign_key :stop_times, :trips, column: :trip_internal_id, primary_key: :internal_id 65 | add_foreign_key :stop_times, :stops, column: :stop_internal_id, primary_key: :internal_id 66 | add_index :stop_times, [:stop_internal_id, :departure_time] 67 | add_index :stop_times, [:trip_internal_id, :departure_time] 68 | 69 | create_table :transfers do |t| 70 | t.string :from_stop_internal_id, null: false 71 | t.string :to_stop_internal_id, null: false 72 | t.integer :min_transfer_time, null: false, default: 0 73 | t.boolean :interchangeable_platforms, null: false, default: false 74 | end 75 | 76 | add_foreign_key :transfers, :stops, column: :from_stop_internal_id, primary_key: :internal_id 77 | add_foreign_key :transfers, :stops, column: :to_stop_internal_id, primary_key: :internal_id 78 | add_index :transfers, :from_stop_internal_id 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/javascript/packs/components/stationModal.scss: -------------------------------------------------------------------------------- 1 | .ui.large.modal.active.station-modal { 2 | @media only screen and (max-width: 767px) { 3 | width: 100% !important; 4 | } 5 | 6 | .close.icon { 7 | color: white; 8 | } 9 | 10 | .header .content { 11 | line-height: 37px; 12 | 13 | .train-list { 14 | display: inline-block; 15 | 16 | .route { 17 | margin-left: 0; 18 | 19 | &.diamond { 20 | margin-left: .25em; 21 | } 22 | } 23 | } 24 | } 25 | 26 | .accessibility-advisories { 27 | .ui.inverted.header { 28 | margin-top: 0; 29 | } 30 | } 31 | 32 | .transfers { 33 | .station-list-item { 34 | .secondary-name { 35 | line-height: 1.3em; 36 | } 37 | 38 | .cross { 39 | height: 21px; 40 | width: 21px; 41 | margin: .25em; 42 | margin-top: 0; 43 | } 44 | } 45 | 46 | .others{ 47 | cursor: default; 48 | } 49 | } 50 | 51 | .trip-table { 52 | .delayed, .late { 53 | color: #ff695e; 54 | } 55 | 56 | .early { 57 | color: #2ecc40; 58 | } 59 | 60 | .unassigned { 61 | font-style: italic; 62 | } 63 | 64 | .delayed-text { 65 | margin-top: 0; 66 | } 67 | 68 | .station-name { 69 | @media only screen and (max-width: 767px) { 70 | display: -webkit-box; 71 | overflow: hidden; 72 | text-overflow: ellipsis; 73 | max-width: 100%; 74 | -webkit-line-clamp: 1; 75 | -webkit-box-orient: vertical; 76 | } 77 | vertical-align: bottom; 78 | } 79 | 80 | .trip-id-link { 81 | @media only screen and (max-width: 767px) { 82 | display: -webkit-box; 83 | overflow: hidden; 84 | text-overflow: ellipsis; 85 | max-width: 100%; 86 | -webkit-line-clamp: 2; 87 | -webkit-box-orient: vertical; 88 | } 89 | 90 | .trip-id { 91 | @media only screen and (max-width: 767px) { 92 | display: -webkit-box; 93 | overflow: hidden; 94 | text-overflow: ellipsis; 95 | max-width: 100%; 96 | -webkit-line-clamp: 1; 97 | -webkit-box-orient: vertical; 98 | vertical-align: bottom; 99 | } 100 | @media only screen and (min-width: 768px) { 101 | display: inline; 102 | } 103 | } 104 | .trip-destination { 105 | @media only screen and (max-width: 767px) { 106 | display: -webkit-box; 107 | overflow: hidden; 108 | text-overflow: ellipsis; 109 | max-width: 100%; 110 | -webkit-line-clamp: 1; 111 | -webkit-box-orient: vertical; 112 | vertical-align: bottom; 113 | } 114 | @media only screen and (min-width: 768px) { 115 | display: inline; 116 | } 117 | } 118 | } 119 | 120 | .more { 121 | @media only screen and (max-width: 767px) { 122 | display: none; 123 | } 124 | } 125 | 126 | .show-more { 127 | font-style: italic; 128 | cursor: pointer; 129 | height: 3em; 130 | 131 | @media only screen and (min-width: 768px) { 132 | display: none; 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/views/slack/help.html.erb: -------------------------------------------------------------------------------- 1 | <% title "Slack - Help" %> 2 | 3 |
4 |

goodservice.io for Slack

5 | 6 | 7 |
8 |

Help

9 |
10 |
11 |

Try typing /goodservice help

12 |
13 | <%= image_tag('slack-help.gif', class: "screenshot") %> 14 |
15 |

More questions or suggestions? Email hello@goodservice.io

16 |
17 |
18 | 19 | Add to Slack 20 | 21 |
22 |
23 | 28 |
29 | -------------------------------------------------------------------------------- /config/initializers/new_framework_defaults_6_1.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains migration options to ease your Rails 6.1 upgrade. 4 | # 5 | # Once upgraded flip defaults one by one to migrate to the new default. 6 | # 7 | # Read the Guide for Upgrading Ruby on Rails for more info on each option. 8 | 9 | # Support for inversing belongs_to -> has_many Active Record associations. 10 | Rails.application.config.active_record.has_many_inversing = true 11 | 12 | # Track Active Storage variants in the database. 13 | Rails.application.config.active_storage.track_variants = true 14 | 15 | # Apply random variation to the delay when retrying failed jobs. 16 | Rails.application.config.active_job.retry_jitter = 0.15 17 | 18 | # Stop executing `after_enqueue`/`after_perform` callbacks if 19 | # `before_enqueue`/`before_perform` respectively halts with `throw :abort`. 20 | Rails.application.config.active_job.skip_after_callbacks_if_terminated = true 21 | 22 | # Specify cookies SameSite protection level: either :none, :lax, or :strict. 23 | # 24 | # This change is not backwards compatible with earlier Rails versions. 25 | # It's best enabled when your entire app is migrated and stable on 6.1. 26 | Rails.application.config.action_dispatch.cookies_same_site_protection = :lax 27 | 28 | # Generate CSRF tokens that are encoded in URL-safe Base64. 29 | # 30 | # This change is not backwards compatible with earlier Rails versions. 31 | # It's best enabled when your entire app is migrated and stable on 6.1. 32 | # Rails.application.config.action_controller.urlsafe_csrf_tokens = true 33 | 34 | # Specify whether `ActiveSupport::TimeZone.utc_to_local` returns a time with an 35 | # UTC offset or a UTC time. 36 | ActiveSupport.utc_to_local_returns_utc_offset_times = true 37 | 38 | # Change the default HTTP status code to `308` when redirecting non-GET/HEAD 39 | # requests to HTTPS in `ActionDispatch::SSL` middleware. 40 | Rails.application.config.action_dispatch.ssl_default_redirect_status = 308 41 | 42 | # Use new connection handling API. For most applications this won't have any 43 | # effect. For applications using multiple databases, this new API provides 44 | # support for granular connection swapping. 45 | # Rails.application.config.active_record.legacy_connection_handling = false 46 | 47 | # Make `form_with` generate non-remote forms by default. 48 | Rails.application.config.action_view.form_with_generates_remote_forms = false 49 | 50 | # Set the default queue name for the analysis job to the queue adapter default. 51 | Rails.application.config.active_storage.queues.analysis = nil 52 | 53 | # Set the default queue name for the purge job to the queue adapter default. 54 | Rails.application.config.active_storage.queues.purge = nil 55 | 56 | # Set the default queue name for the incineration job to the queue adapter default. 57 | Rails.application.config.action_mailbox.queues.incineration = nil 58 | 59 | # Set the default queue name for the routing job to the queue adapter default. 60 | Rails.application.config.action_mailbox.queues.routing = nil 61 | 62 | # Set the default queue name for the mail deliver job to the queue adapter default. 63 | Rails.application.config.action_mailer.deliver_later_queue_name = nil 64 | 65 | # Generate a `Link` header that gives a hint to modern browsers about 66 | # preloading assets when using `javascript_include_tag` and `stylesheet_link_tag`. 67 | Rails.application.config.action_view.preload_links_header = true 68 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= 65 | env_var_version || cli_arg_version || 66 | lockfile_version 67 | end 68 | 69 | def bundler_requirement 70 | return "#{Gem::Requirement.default}.a" unless bundler_version 71 | 72 | bundler_gem_version = Gem::Version.new(bundler_version) 73 | 74 | requirement = bundler_gem_version.approximate_recommendation 75 | 76 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0") 77 | 78 | requirement += ".a" if bundler_gem_version.prerelease? 79 | 80 | requirement 81 | end 82 | 83 | def load_bundler! 84 | ENV["BUNDLE_GEMFILE"] ||= gemfile 85 | 86 | activate_bundler 87 | end 88 | 89 | def activate_bundler 90 | gem_error = activation_error_handling do 91 | gem "bundler", bundler_requirement 92 | end 93 | return if gem_error.nil? 94 | require_error = activation_error_handling do 95 | require "bundler/version" 96 | end 97 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 98 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 99 | exit 42 100 | end 101 | 102 | def activation_error_handling 103 | yield 104 | nil 105 | rescue StandardError, LoadError => e 106 | e 107 | end 108 | end 109 | 110 | m.load_bundler! 111 | 112 | if m.invoked_as_script? 113 | load Gem.bin_path("bundler", "bundle") 114 | end 115 | -------------------------------------------------------------------------------- /app/javascript/packs/components/trainMapStop.scss: -------------------------------------------------------------------------------- 1 | .train-map-stop { 2 | .container { 3 | min-height: 50px; 4 | display: flex; 5 | 6 | .left-margin { 7 | min-width: 40px; 8 | } 9 | 10 | .travel-time { 11 | min-width: 40px; 12 | max-width: 40px; 13 | margin: auto 0; 14 | display: inline; 15 | text-align: center; 16 | margin-top: 0 !important; 17 | 18 | .warning { 19 | color: #ff695e; 20 | } 21 | } 22 | 23 | .station-name { 24 | display: inline; 25 | margin: auto 0; 26 | // cursor: pointer; 27 | 28 | .secondary-name { 29 | font-weight: normal; 30 | font-size: 12px; 31 | margin: 0 .5em; 32 | } 33 | 34 | .icon:only-child { 35 | margin-right: 0; 36 | } 37 | } 38 | 39 | .transfers { 40 | display: inline-block; 41 | margin: auto 0; 42 | 43 | .ui.label { 44 | padding: .3em .5em; 45 | white-space: nowrap; 46 | 47 | .icon { 48 | margin-right: .3em; 49 | } 50 | 51 | &.blue { 52 | background-color: #2596be !important; 53 | border-color: #2596be !important; 54 | } 55 | 56 | &:first-child { 57 | margin-left: .5em; 58 | } 59 | } 60 | } 61 | 62 | .both-stop { 63 | border: 1px #999 solid; 64 | height: 10px; 65 | width: 10px; 66 | border-radius: 50%; 67 | position: relative; 68 | background-color: white; 69 | left: 5px; 70 | top: 20px; 71 | // cursor: pointer; 72 | } 73 | 74 | .north-stop { 75 | border: 1px #999 solid; 76 | height: 5px; 77 | width: 10px; 78 | border-top-left-radius: 10px; 79 | border-top-right-radius: 10px; 80 | position: relative; 81 | background-color: white; 82 | left: 5px; 83 | top: 20px; 84 | // cursor: pointer; 85 | } 86 | 87 | .south-stop { 88 | border: 1px #999 solid; 89 | height: 5px; 90 | width: 10px; 91 | border-bottom-left-radius: 10px; 92 | border-bottom-right-radius: 10px; 93 | position: relative; 94 | background-color: white; 95 | left: 5px; 96 | top: 25px; 97 | // cursor: pointer; 98 | } 99 | 100 | .trip-at { 101 | height: 16px; 102 | width: 16px; 103 | position: relative; 104 | background-color: rgba(0,0,0,.4); 105 | border-radius: 50%; 106 | left: -4px; 107 | top: -7px; 108 | 109 | &.delayed { 110 | animation: blink 1s infinite; 111 | } 112 | } 113 | 114 | .trip-before { 115 | height: 16px; 116 | width: 16px; 117 | position: relative; 118 | border-radius: 50%; 119 | background-color: rgba(0,0,0,.4); 120 | left: -4px; 121 | top: -34px; 122 | 123 | &.delayed { 124 | animation: blink 1s infinite; 125 | } 126 | } 127 | 128 | @keyframes blink { 129 | 50% { 130 | background-color: rgba(255, 0, 0, 1); 131 | } 132 | } 133 | 134 | .branch-corner { 135 | overflow: hidden; 136 | width: 40px; 137 | display: inline-block; 138 | } 139 | 140 | .branch-start { 141 | height: calc(100% - 35px); 142 | width: 30px; 143 | border-top-right-radius: 50%; 144 | display: block; 145 | } 146 | 147 | .branch-end { 148 | height: 100%; 149 | width: 30px; 150 | border-bottom-right-radius: 50%; 151 | display: block; 152 | } 153 | } 154 | } -------------------------------------------------------------------------------- /app/javascript/packs/components/trainGrid.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Grid, Header } from "semantic-ui-react"; 3 | import { Link } from 'react-router-dom'; 4 | import { groupBy } from 'lodash'; 5 | 6 | import Train from './train'; 7 | import TrainBullet from './trainBullet'; 8 | 9 | import './trainGrid.scss'; 10 | 11 | const STATUSES = { 12 | 'Delay': 'red', 13 | 'No Service': 'black', 14 | 'Service Change': 'orange', 15 | 'Slow': 'yellow', 16 | 'Not Good': 'yellow', 17 | 'Good Service': 'green', 18 | 'Not Scheduled': 'black', 19 | 'No Data': 'black', 20 | }; 21 | 22 | 23 | class TrainGrid extends React.Component { 24 | 25 | render() { 26 | const { selectedTrain, trains, stations } = this.props; 27 | const trainKeys = Object.keys(trains); 28 | let groups = groupBy(trains, 'status'); 29 | const stationsObj = {}; 30 | stations.forEach((s) => { 31 | stationsObj[s.id] = s; 32 | }) 33 | return ( 34 | 35 | 36 | { 37 | stations && trainKeys.map(trainId => trains[trainId]).sort((a, b) => { 38 | const nameA = `${a.name} ${a.alternate_name}`; 39 | const nameB = `${b.name} ${b.alternate_name}`; 40 | if (nameA < nameB) { 41 | return -1; 42 | } 43 | if (nameA > nameB) { 44 | return 1; 45 | } 46 | return 0; 47 | }).map(train => { 48 | const visible = train.visible || train.status !== 'Not Scheduled'; 49 | return ( 50 | 51 | 52 | ) 53 | }) 54 | } 55 | 56 | 57 | { 58 | Object.keys(STATUSES).filter((s) => groups[s] && groups[s].some((t) => t.visible || s !== "Not Scheduled")).map((status) => { 59 | return ( 60 | 61 | 62 |
{status}
63 |
64 | 65 | { 66 | groups[status].map(train => { 67 | const alternateName = train.alternate_name; 68 | const visible = train.visible || train.status !== 'Not Scheduled'; 69 | let displayAlternateName = alternateName && alternateName[0]; 70 | let match; 71 | if (match = alternateName?.match(/^(?[0-9]+)/)) { 72 | displayAlternateName = match.groups.number; 73 | } 74 | return ( 75 | 76 | 77 | 79 | 80 | 81 | ) 82 | }) 83 | } 84 | 85 |
86 | ) 87 | }) 88 | } 89 |
90 |
91 | ); 92 | } 93 | } 94 | 95 | export default TrainGrid; -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Subway Now lite (formerly goodservice.io) - <%= content_for?(:title) ? yield(:title) : 'New York City Subway Status Page' %> 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | <%= stylesheet_pack_tag 'components/app' %> 8 | <%= javascript_pack_tag 'application' %> 9 | 10 | 11 | 12 | 13 | <%= stylesheet_pack_tag 'vendor/smartbanner' %> 14 | <%= javascript_packs_with_chunks_tag 'vendor/smartbanner' %> 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | <%= tag :meta, property: 'og:image', content: image_url('screenshot.png') %> 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | <%= tag :meta, property: 'twitter:image', content: image_url('screenshot.png') %> 45 | 46 | <%= favicon_link_tag asset_path('favicon.png'), rel: 'icon' %> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | <%= yield %> 57 | 58 | 59 | -------------------------------------------------------------------------------- /app/javascript/packs/components/footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Image, Segment, Grid, Header, List } from "semantic-ui-react"; 3 | import { Link } from 'react-router-dom'; 4 | import googleplay from "./images/googleplay.png"; 5 | import './footer.scss'; 6 | 7 | class Footer extends React.Component { 8 | 9 | render() { 10 | const { timestamp } = this.props; 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | Download on the App Store 18 | 19 | 20 | Download on Google Play 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | Alexa 29 | 30 | 31 | 32 | 33 | Tidbyt 34 | 35 | 36 | 37 | 38 | Slack 39 | 40 | 41 | 42 | Twitter 43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | 51 | Our blog 52 | 53 | 54 | 55 | 56 | Subway Now - Real-Time Subway Map for NYC 57 | 58 | 59 | 60 | 61 | Subwaydle - Daily Subway Puzzle Game 62 | 63 | 64 | 65 | 66 | 67 |
68 | Created by Sunny Ng. 69 |
70 |

71 | Last updated {timestamp && (new Date(timestamp * 1000)).toLocaleTimeString('en-US')}.
72 | Source code.
73 | Subway Route Symbols ®: Metropolitan Transportation Authority. Used with permission. 74 |

75 |
76 | 77 | 78 | 79 | ); 80 | } 81 | 82 | 83 | } 84 | 85 | export default Footer; -------------------------------------------------------------------------------- /app/javascript/packs/components/trainModalOverviewPane.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Header, Segment, Statistic, Grid, Label } from "semantic-ui-react"; 3 | 4 | import TrainMap from './trainMap'; 5 | import { statusColor, formatStation, replaceTrainBulletsInParagraphs, twitterLink } from './utils'; 6 | 7 | import './trainModalOverviewPane.scss'; 8 | 9 | class TrainModalOverviewPane extends React.Component { 10 | renderDelays() { 11 | const { train } = this.props; 12 | let out = []; 13 | if (!train.delay_summaries) { 14 | return out; 15 | } 16 | if (train.delay_summaries["north"]) { 17 | out.push(
{formatStation(train.delay_summaries.north)}
) 18 | } 19 | if (train.delay_summaries["south"]) { 20 | out.push(
{formatStation(train.delay_summaries.south)}
) 21 | } 22 | 23 | if (out.length) { 24 | return ( 25 | 26 | 27 | { 28 | out 29 | } 30 | 31 | ); 32 | } 33 | } 34 | 35 | renderServiceChanges() { 36 | const { train, trains } = this.props; 37 | 38 | if (!train.service_change_summaries) { 39 | return; 40 | } 41 | 42 | const summaries = Object.keys(train.service_change_summaries).map((key) => train.service_change_summaries[key]).flat(); 43 | if (summaries.length) { 44 | return ( 45 | 46 | 47 | { 48 | replaceTrainBulletsInParagraphs(trains, summaries) 49 | } 50 | 51 | ); 52 | } 53 | } 54 | 55 | renderServiceIrregularities() { 56 | const { train } = this.props; 57 | let out = []; 58 | if (!train.service_irregularity_summaries) { 59 | return out; 60 | } 61 | if (train.service_irregularity_summaries["north"]) { 62 | out.push(
{formatStation(train.service_irregularity_summaries.north)}
) 63 | } 64 | if (train.service_irregularity_summaries["south"]) { 65 | out.push(
{formatStation(train.service_irregularity_summaries.south)}
) 66 | } 67 | 68 | if (out.length) { 69 | return ( 70 | 71 | 72 | { 73 | out 74 | } 75 | 76 | ); 77 | } 78 | } 79 | 80 | render() { 81 | const { train, trains, stations } = this.props; 82 | return ( 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | {train.status} 93 | 94 | Status 95 | { twitterLink(train.id) } 96 | 97 | 98 | 99 | { 100 | this.renderDelays() 101 | } 102 | { 103 | this.renderServiceChanges() 104 | } 105 | { 106 | this.renderServiceIrregularities() 107 | } 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | ) 116 | } 117 | } 118 | 119 | export default TrainModalOverviewPane; -------------------------------------------------------------------------------- /app/javascript/packs/components/aboutModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Header, Modal } from 'semantic-ui-react'; 3 | import { withRouter } from 'react-router-dom'; 4 | import { Helmet } from "react-helmet"; 5 | 6 | class AboutModal extends React.Component { 7 | handleOnClose = () => { 8 | const { history } = this.props; 9 | return history.push('/'); 10 | }; 11 | 12 | render() { 13 | return( 14 | 17 | 18 | Subway Now lite (formerly goodservice.io) - About 19 | 20 | 21 | 22 | 23 | 24 | 25 | What is Good Service? 26 | 27 | 28 | 29 |

30 | goodservice.io's goal is to provide an up-to-date and detailed view of the New York City subway system 31 | using the publicly available GTFS and GTFS-RT data. It is an open source project, and 32 | the source code can be found on GitHub. 33 | Currently, it displays live route maps, maximum wait times (i.e. train headways or 34 | frequency), train delays, and estimated train arrival times based on past train departure times. 35 |

36 |

37 | The statuses displayed are defined as follow (in the order of how they are assigned): 38 |

    39 |
  • Delay: Any train associated with the train or line has been detected to not move in at least 5 minutes.
  • 40 |
  • Service Change: Any train associated with the train or line is stopping at different stations than what are scheduled.
  • 41 |
  • Slow: Accumulated runtime of trains between each pair of stations exceed 5 minutes for its run.
  • 42 |
  • Not Good: Difference in maximum scheduled and actual wait for a train is greater or equal to 3 minutes.
  • 43 |
  • No Service: Train scheduled to run but no trains detected.
  • 44 |
  • Not Scheduled: Train not currently scheduled to run.
  • 45 |
  • Good Service: None of the above, hooray! 🎉
  • 46 |
47 |

48 | 49 |

50 | The Good Service Blog is where you can find me writing about transit, 51 | but mostly about this site. Some highlights: 52 | 53 |

60 |

61 | 62 |

Other transit projects I've been working on: 63 | 64 |

68 |

69 |
70 |
71 |
72 | ) 73 | } 74 | } 75 | export default withRouter(AboutModal); -------------------------------------------------------------------------------- /app/javascript/packs/components/trainModalDirectionPane.scss: -------------------------------------------------------------------------------- 1 | .ui.segment.train-modal-direction-pane { 2 | @media only screen and (max-width: 767px) { 3 | padding-left: 0; 4 | padding-right: 0; 5 | } 6 | 7 | .ui.fluid.selection.dropdown { 8 | background: #1b1c1d; 9 | 10 | .divider.text, .dropdown.icon { 11 | color: white; 12 | } 13 | } 14 | 15 | .minute { 16 | font-size: 1rem; 17 | } 18 | 19 | .travel-time-header { 20 | margin-top: 0; 21 | 22 | .dropdown { 23 | margin-left: 1em; 24 | margin-right: 1em; 25 | } 26 | } 27 | 28 | .additional-trips-toggle { 29 | .toggle-label { 30 | color: #ccc !important; 31 | } 32 | 33 | &.ui.toggle.checkbox input:focus:checked~label.toggle-label { 34 | color: #ccc !important; 35 | } 36 | 37 | .ui.toggle.checkbox input:focus:checked~.box, .ui.toggle.checkbox input:focus:checked~label { 38 | color: #ccc !important; 39 | } 40 | 41 | .ui.toggle.checkbox .box:before, .ui.toggle.checkbox label.toggle-label:before, .ui.toggle.checkbox input:focus~.box:before, .ui.toggle.checkbox input:focus~label.toggle-label:before, .ui.toggle.checkbox .box:hover::before, .ui.toggle.checkbox label.toggle-label:hover::before { 42 | background: rgba(255,255,255,.05) 43 | } 44 | } 45 | 46 | .trip-table { 47 | .delayed, .late { 48 | color: #ff695e; 49 | } 50 | 51 | .delayed-text { 52 | margin-top: 0; 53 | } 54 | 55 | .unassigned { 56 | font-style: italic; 57 | } 58 | 59 | .long-headway { 60 | color: #ffe21f; 61 | } 62 | 63 | .early { 64 | color: #2ecc40; 65 | } 66 | 67 | .station-name { 68 | @media only screen and (max-width: 767px) { 69 | display: -webkit-box; 70 | overflow: hidden; 71 | text-overflow: ellipsis; 72 | max-width: 100%; 73 | -webkit-line-clamp: 1; 74 | -webkit-box-orient: vertical; 75 | } 76 | vertical-align: bottom; 77 | } 78 | 79 | .trip-id-link { 80 | @media only screen and (max-width: 767px) { 81 | display: -webkit-box; 82 | overflow: hidden; 83 | text-overflow: ellipsis; 84 | max-width: 100%; 85 | -webkit-line-clamp: 2; 86 | -webkit-box-orient: vertical; 87 | } 88 | 89 | .trip-id { 90 | @media only screen and (max-width: 767px) { 91 | display: -webkit-box; 92 | overflow: hidden; 93 | text-overflow: ellipsis; 94 | max-width: 100%; 95 | -webkit-line-clamp: 1; 96 | -webkit-box-orient: vertical; 97 | vertical-align: bottom; 98 | } 99 | @media only screen and (min-width: 768px) { 100 | display: inline; 101 | } 102 | } 103 | .trip-destination { 104 | @media only screen and (max-width: 767px) { 105 | display: -webkit-box; 106 | overflow: hidden; 107 | text-overflow: ellipsis; 108 | max-width: 100%; 109 | -webkit-line-clamp: 1; 110 | -webkit-box-orient: vertical; 111 | vertical-align: bottom; 112 | } 113 | @media only screen and (min-width: 768px) { 114 | display: inline; 115 | } 116 | } 117 | } 118 | 119 | th span { 120 | @media only screen and (max-width: 767px) { 121 | display: -webkit-box; 122 | overflow: hidden; 123 | text-overflow: ellipsis; 124 | -webkit-line-clamp: 3; 125 | -webkit-box-orient: vertical; 126 | } 127 | } 128 | 129 | .concurrent-trains { 130 | @media only screen and (max-width: 767px) { 131 | display: block; 132 | } 133 | } 134 | } 135 | 136 | .ui.stackable.grid>.row>.column.trip-table-cell { 137 | @media only screen and (max-width: 767px) { 138 | margin-left: -1rem !important; 139 | margin-right: -1rem !important; 140 | 141 | padding-left: 0 !important; 142 | padding-right: 0 !important; 143 | } 144 | } 145 | 146 | .ui.stackable.grid>.row>.column.map-cell { 147 | @media only screen and (max-width: 767px) { 148 | display: none; 149 | } 150 | } 151 | 152 | .ui.stackable.grid>.row>.column.mobile-map-cell { 153 | @media only screen and (min-width: 768px) { 154 | display: none; 155 | } 156 | } 157 | 158 | .table-with-heading { 159 | margin-top: 1em; 160 | } 161 | 162 | .ui.small.statistics .statistic>.value { 163 | @media only screen and (max-width: 767px) { 164 | font-size: 2rem!important; 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /app/javascript/packs/components/app.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background: #1b1c1d !important; 3 | } 4 | 5 | a { 6 | color: #bbbbbb; 7 | } 8 | 9 | a:hover, a:active{ 10 | color: #ffe21f; 11 | } 12 | 13 | .blurring.dimmed.dimmable > :not(.app):not(.modals) { 14 | filter: none; 15 | } 16 | 17 | .ui.inverted.segment.header-segment { 18 | padding: 2em 2em 1em 2em; 19 | } 20 | 21 | .ui.inverted.segment.blogpost-segment { 22 | background-color: #333333; 23 | padding: 1em 2em; 24 | } 25 | 26 | .ui.basic.segment.content-segment { 27 | padding-top: 0; 28 | margin: 0 1em; 29 | 30 | .ui.inverted.bottom.attached.segment.active.tab { 31 | background-color: #002D72; 32 | padding: 2em; 33 | margin-top: 0; 34 | padding: 1em; 35 | } 36 | } 37 | 38 | .about-link { 39 | cursor: pointer; 40 | } 41 | 42 | .twitter-link { 43 | text-transform: none; 44 | } 45 | 46 | @font-face { 47 | font-family: 'fontello'; 48 | src: url('data:application/octet-stream;base64,d09GRgABAAAAAAtoAA8AAAAAFIQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABWAAAADsAAABUIIslek9TLzIAAAGUAAAAQwAAAFY+IEl4Y21hcAAAAdgAAABKAAABcOkou6pjdnQgAAACJAAAABMAAAAgBtf/BGZwZ20AAAI4AAAFkAAAC3CKkZBZZ2FzcAAAB8gAAAAIAAAACAAAABBnbHlmAAAH0AAAAO8AAAGmpJl77GhlYWQAAAjAAAAAMAAAADYUUV0GaGhlYQAACPAAAAAeAAAAJAc+A1RobXR4AAAJEAAAAAgAAAAIB9AAAGxvY2EAAAkYAAAABgAAAAYA0wAAbWF4cAAACSAAAAAgAAAAIAEaDBpuYW1lAAAJQAAAAXcAAALNzJ0fIXBvc3QAAAq4AAAAMgAAAEci69u5cHJlcAAACuwAAAB6AAAAhuVBK7x4nGNgZGBg4GIwYLBjYHJx8wlh4MtJLMljkGJgYYAAkDwymzEnMz2RgQPGA8qxgGkOIGaDiAIAJjsFSAB4nGNgZH7BOIGBlYGBqYppDwMDQw+EZnzAYMjIBBRlYGVmwAoC0lxTGBxeMLxgYA76n8UQxRzMMA0ozAiSAwAKeAwWAHic7ZCxDYAwEAPPykOBGIEqNbNQsX/NFsnHwBaxdJZ8+uqBBSjJmQToRoxcaWVf2OyD6psY/qG1v3OHG63M7O7jW/7VC+ot1glxAAB4nGNgQAMSEMgc/D8LhAESdgPfAHicrVZpd9NGFB15SZyELCULLWphxMRpsEYmbMGACUGyYyBdnK2VoIsUO+m+8Ynf4F/zZNpz6Dd+Wu8bLySQtOdwmpOjd+fN1czbZRJaktgL65GUmy/F1NYmjew8CemGTctRfCg7eyFlisnfBVEQrZbatx2HREQiULWusEQQ+x5ZmmR86FFGy7akV03KLT3pLlvjQb1V334aOsqxO6GkZjN0aD2yJVUYVaJIpj1S0qZlqPorSSu8v8LMV81QwohOImm8GcbQSN4bZ7TKaDW24yiKbLLcKFIkmuFBFHmU1RLn5IoJDMoHzZDyyqcR5cP8iKzYo5xWsEu20/y+L3mndzk/sV9vUbbkQB/Ijuzg7HQlX4RbW2HctJPtKFQRdtd3QmzZ7FT/Zo/ymkYDtysyvdCMYKl8hRArP6HM/iFZLZxP+ZJHo1qykRNB62VO7Es+gdbjiClxzRhZ0N3RCRHU/ZIzDPaYPh788d4plgsTAngcy3pHJZwIEylhczRJ2jByYCVliyqp9a6YOOV1WsRbwn7t2tGXzmjjUHdiPFsPHVs5UcnxaFKnmUyd2knNoykNopR0JnjMrwMoP6JJXm1jNYmVR9M4ZsaERCICLdxLU0EsO7GkKQTNoxm9uRumuXYtWqTJA/Xco/f05la4udNT2g70s0Z/VqdiOtgL0+lp5C/xadrlIkXp+ukZfkziQdYCMpEtNsOUgwdv/Q7Sy9eWHIXXBtju7fMrqH3WRPCkAfsb0B5P1SkJTIWYVYhWQGKta1mWydWsFqnI1HdDmla+rNMEinIcF8e+jHH9XzMzlpgSvt+J07MjLj1z7UsI0xx8m3U9mtepxXIBcWZ5TqdZlu/rNMfyA53mWZ7X6QhLW6ejLD/UaYHlRzodY3lBC5p038GQizDkAg6QMISlA0NYXoIhLBUMYbkIQ1gWYQjLJRjC8mMYwnIZhrC8rGXV1FNJ49qZWAZsQmBijh65zEXlaiq5VEK7aFRqQ54SbpVUFM+qf2WgXjzyhjmwFkiXyJpfMc6Vj0bl+NYVLW8aO1fAsepvH472OfFS1ouFPwX/1dZUJb1izcOTq/Abhp5sJ6o2qXh0TZfPVT26/l9UVFgL9BtIhVgoyrJscGcihI86nYZqoJVDzGzMPLTrdcuan8P9NzFCFlD9+DcUGgvcg05ZSVnt4KzV19uy3DuDcjgTLEkxN/P6VvgiI7PSfpFZyp6PfB5wBYxKZdhqA60VvNknMQ+Z3iTPBHFbUTZI2tjOBIkNHPOAefOdBCZh6qoN5E7hhg34BWFuwXknXKJ6oyyH7kXs8yik/Fun4kT2qGiMwLPZG2Gv70LKb3EMJDT5pX4MVBWhqRg1FdA0Um6oBl/G2bptQsYO9CMqdsOyrOLDxxb3lZJtGYR8pIjVo6Of1l6iTqrcfmYUl++dvgXBIDUxf3vfdHGQyrtayTJHbQNTtxqVU9eaQ+NVh+rmUfW94+wTOWuabronHnpf06rbwcVcLLD2bQ7SUiYX1PVhhQ2iy8WlUOplNEnvuAcYFhjQ71CKjf+r+th8nitVhdFxJN9O1LfR52AM/A/Yf0f1A9D3Y+hyDS7P95oTn2704WyZrqIX66foNzBrrblZugbc0HQD4iFHrY64yg18pwZxeqS5HOkh4GPdFeIBwCaAxeAT3bWM5lMAo/mMOT7A58xh0GQOgy3mMNhmzhrADnMY7DKHwR5zGHzBnHWAL5nDIGQOg4g5DJ4wJwB4yhwGXzGHwdfMYfANc+4DfMscBjFzGCTMYbCv6dYwzC1e0F2gtkFVoANTT1jcw+JQU2XI/o4Xhv29Qcz+wSCm/qjp9pD6Ey8M9WeDmPqLQUz9VdOdIfU3Xhjq7wYx9Q+DmPpMvxjLZQa/jHyXCgeUXWw+5++J9w/bxUC5AAEAAf//AA94nGPgYGD4n8X8kjmYgZtBmkGdwZzBmSGYIZ4hnyHZIYFDkImNi5OJnZGNPYGfiYlRmI+JgYeRIUGAkYWFNYKbkZWVx0OIkYeHOYKXkZmZl9kzMSE0RFNDViYzLSE/MS86KiQ+ND7A38Pd2dHG2lBPw1zTTFlJRl1WXVxEVViEVULbXISPUUmP0cSO0ZpRHEgayTGKCkLETIUhEkAhWUZ2IAkUVlaEiBmzQiSAQtqM6lD1glD9zBAJoJAtozlUvSJU/yUZAysDGRkD2UxTKAtIZMqiMGT//YTQTGuJUc3IDqFvUcWUv8HYVAMARi9EdAB4nGNgZGBgAOIzK70y4vltvjJwM78AijDcWNKzCUH/z2J+yRwM5HIwMIFEAW/jDOh4nGNgZGBgDvqfBSRfMDD8/8/8kgEoggKYAIfeBZgAAAPoAAAD6AAAAAAAAADTAAAAAQAAAAIAcAAIAAAAAAACACgAOABzAAAAdAtwAAAAAHicdZDLTsJAFIb/kYsKiRpN3DorAzGWS+ICEhISDGx0QwxbU0ppS0qHTAcSXsN38GF8CZ/Fn3YwBmKb6XznmzNnTgfANb4hkD9PHDkLnDHK+QSn6Fku0D9bLpJfLJdQxZvlMv275QoeEFiu4gYfrCCK54wW+LQscCUuLZ/gQtxZLtA/Wi6Se5ZLuBWvlsv0nuUKJiK1XMW9+Bqo1VZHQWhkbVCX7WarI6dbqaiixI2luzah0qnsy7lKjB/HyvHUcs9jP1jHrt6H+3ni6zRSiWw5zb0a+YmvXePPdtXTTdA2Zi7nWi3l0GbIlVYL3zNOaMyq22j8PQ8DKKywhUbEqwphIFGjrXNuo4kWOqQpMyQz86wICVzENC7W3BFmKynjPsecUULrMyMmO/D4XR75MSng/phV9NHqYTwh7c6IMi/Zl8PuDrNGpCTLdDM7++09xYantWkNd+261FlXEsODGpL3sVtb0Hj0TnYrhraLBt9//u8H7HiEVQB4nGNgYoAALgbsgImRiZGZQbY4JzE5Oz43sQhI5OflJ2cU5eemxieBRHWNGBgA0MYLnAAAeJxj8N7BcCIoYiMjY1/kBsadHAwcDMkFGxlYnTYxMDJogRibuZgYOSAsPgYwi81pF9MBoDQnkM3utIvBAcJmZnDZqMLYERixwaEjYiNzistGNRBvF0cDAyOLQ0dySARISSQQbOZhYuTR2sH4v3UDS+9GJgYXAAx2I/QAAA==') format('woff'), 49 | } 50 | i.icon.slack-icon { 51 | font-family: "fontello"; 52 | } 53 | i.icon.slack-icon:before { content: '\e800'; } 54 | button.ui.medium-icon { 55 | background-color: white; 56 | color: rgba(0,0,0,.84); 57 | } 58 | button.ui.slack-icon { 59 | background-color: #611f69; 60 | color: white; 61 | 62 | &:hover { 63 | background-color: #7c3085; 64 | color: white; 65 | } 66 | } 67 | 68 | img.screenshot { 69 | max-width: 100%; 70 | } 71 | -------------------------------------------------------------------------------- /app/views/slack/privacy.html.erb: -------------------------------------------------------------------------------- 1 | <% title "Slack - Privacy Policy" %> 2 | 3 |
4 |

goodservice.io for Slack

5 | 6 | 7 |
8 |

Privacy Policy

9 |
10 |
11 |

TL;DR

12 |

goodservice.io does not want to know who you are and does not store any of your personal information.

13 |

1 - Definition and Nature of Personal Data

14 |

During your use of goodservice.io, we do not require you to provide us with any personal data.

15 |

Information Gathering

16 |
2.1 - Information Collected About Your Team from Slack
17 |

We are given your team's Slack ID, Team Name and Domain upon registration and upon each command call, but we do not store any of it.

18 |
2.2 - Information You Provide to Us
19 |

We do not require that you provide us with any personal data to enable functionality of goodservice.io.

20 |
2.3 - Information Collected via Technology
21 |

We collect and log a minimal amount of activity data related to interactions had with goodservice.io via Google Analytics. This includes but is not limited to, Location request, webpages accessed, IP addressed, browser types, and operating system. Please refer to Google Analytics' Privacy & Terms on how Google Analytics uses the information.

22 |

3 - Transfer or Sale of Personal Data

23 |

We shall not sell, transfer or lease out your personal data to third parties.

24 |

4 - Safety

25 |

We reserve the right, at our sole discretion, to modify this Privacy policy or any portion thereof. Any changes will be effective from the time of publication of the new Privacy policy. Your use of the Website after the changes have been implemented implicitly expresses your acknowledgement and acceptance of the new Privacy policy. Otherwise, and if the new Privacy policy does not suit you, you must no longer use the Services.

26 |

5 - Modifications

27 |

We reserve the right, at our sole discretion, to modify this Privacy policy or any portion thereof. Any changes will be effective from the time of publication of the new Privacy policy. Your use of the Website after the changes have been implemented implicitly expresses your acknowledgement and acceptance of the new Privacy policy. Otherwise, and if the new Privacy policy does not suit you, you must no longer use the Services.

28 |
29 |
30 | 31 | Add to Slack 32 | 33 |
34 |
35 | 40 |
41 | -------------------------------------------------------------------------------- /app/controllers/api/routes_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::RoutesController < ApplicationController 2 | def index 3 | detailed = params[:detailed] == '1' 4 | data = Rails.cache.fetch(detailed ? "status-detailed" : "status", expires_in: 10.seconds) do 5 | data_hash = detailed ? RedisStore.route_status_detailed_summaries : RedisStore.route_status_summaries 6 | scheduled_routes = Scheduled::Trip.soon(Time.current.to_i, nil).pluck(:route_internal_id).to_set 7 | timestamps = [] 8 | { 9 | routes: Scheduled::Route.all.sort_by { |r| "#{r.name} #{r.alternate_name}" }.map { |route| 10 | route_data_encoded = data_hash[route.internal_id] 11 | route_data = route_data_encoded ? JSON.parse(route_data_encoded.gsub(/\(\(/, '').gsub(/\)\)/, '')) : {} 12 | if !route_data['timestamp'] || route_data['timestamp'] < (Time.current - 5.minutes).to_i 13 | route_data = {} 14 | else 15 | timestamps << route_data['timestamp'] 16 | end 17 | scheduled = scheduled_routes.include?(route.internal_id) 18 | default_status = "No Service" 19 | service_change_summaries = {} 20 | feed_timestamp = RedisStore.feed_timestamp(FeedRetrieverSpawningWorkerBase.feed_id_for(route.internal_id)) 21 | 22 | if !scheduled 23 | default_status = "Not Scheduled" 24 | elsif feed_timestamp && feed_timestamp.to_i < (Time.current - 5.minutes).to_i 25 | default_status = "No Data" 26 | else 27 | service_change_summaries = { 28 | both: [ 29 | "<#{route.internal_id}> trains are not running." 30 | ] 31 | } 32 | end 33 | 34 | data = { 35 | id: route.internal_id, 36 | name: route.name, 37 | color: route.color && "##{route.color}", 38 | text_color: route.text_color && "##{route.text_color}", 39 | alternate_name: route.alternate_name, 40 | status: default_status, 41 | visible: route.visible?, 42 | scheduled: scheduled, 43 | } 44 | 45 | if detailed 46 | data[:service_change_summaries] = service_change_summaries 47 | end 48 | 49 | [route.internal_id, data.merge(route_data).except('timestamp')] 50 | }.to_h, 51 | timestamp: timestamps.max 52 | } 53 | end 54 | 55 | expires_now 56 | render json: data 57 | end 58 | 59 | def show 60 | route_id = params[:id] 61 | data = Rails.cache.fetch("status:#{route_id}", expires_in: 10.seconds) do 62 | route = Scheduled::Route.find_by!(internal_id: route_id) 63 | scheduled = Scheduled::Trip.soon(Time.current.to_i, route_id).present? 64 | route_data_encoded = RedisStore.route_status(route_id) 65 | route_data = route_data_encoded ? JSON.parse(route_data_encoded.gsub(/\(\(/, '').gsub(/\)\)/, '')) : {} 66 | if !route_data['timestamp'] || route_data['timestamp'] <= (Time.current - 5.minutes).to_i 67 | route_data = {} 68 | else 69 | pairs = route_pairs(route_data['actual_routings']) 70 | 71 | if pairs.present? 72 | route_data[:scheduled_travel_times] = scheduled_travel_times(pairs) 73 | route_data[:supplemented_travel_times] = supplemented_travel_times(pairs) 74 | route_data[:estimated_travel_times] = estimated_travel_times(route_data['actual_routings'], pairs, route_data['timestamp']) 75 | end 76 | end 77 | 78 | default_status = "No Service" 79 | service_change_summaries = {} 80 | feed_timestamp = RedisStore.feed_timestamp(FeedRetrieverSpawningWorkerBase.feed_id_for(route.internal_id)) 81 | if !scheduled 82 | default_status = "Not Scheduled" 83 | elsif feed_timestamp && feed_timestamp.to_i < (Time.current - 5.minutes).to_i 84 | default_status = "No Data" 85 | else 86 | service_change_summaries = { 87 | both: [ 88 | "<#{route.internal_id}> trains are not running." 89 | ] 90 | } 91 | end 92 | { 93 | id: route.internal_id, 94 | name: route.name, 95 | color: route.color && "##{route.color}", 96 | text_color: route.text_color && "##{route.text_color}", 97 | alternate_name: route.alternate_name, 98 | status: default_status, 99 | visible: route.visible?, 100 | scheduled: scheduled, 101 | service_change_summaries: service_change_summaries, 102 | timestamp: Time.current.to_i, 103 | }.merge(route_data) 104 | end 105 | 106 | expires_now 107 | render json: data 108 | end 109 | 110 | private 111 | 112 | def route_pairs(routings) 113 | routings.map { |_, r| r.map { |routing| routing.each_cons(2).map { |a, b| [a, b] }}}.flatten(2).uniq 114 | end 115 | 116 | def scheduled_travel_times(pairs) 117 | results = RedisStore.scheduled_travel_times(pairs) 118 | results.to_h do |k, v| 119 | stops = k.split("-") 120 | [k, v ? v.to_i : RedisStore.supplemented_scheduled_travel_time(stops.first, stops.second)] 121 | end 122 | end 123 | 124 | def supplemented_travel_times(pairs) 125 | results = RedisStore.supplemented_scheduled_travel_times(pairs) 126 | results.to_h do |k, v| 127 | stops = k.split("-") 128 | [k, v ? v.to_i : RedisStore.scheduled_travel_time(stops.first, stops.second)] 129 | end 130 | end 131 | 132 | def estimated_travel_times(routings, pairs, timestamp) 133 | travel_times_data = RedisStore.travel_times 134 | travel_times = travel_times_data ? Marshal.load(travel_times_data) : {} 135 | 136 | pairs.to_h { |pair| 137 | pair_str = "#{pair.first}-#{pair.second}" 138 | [pair_str, travel_times[pair_str] || RedisStore.supplemented_scheduled_travel_time(pair.first, pair.second) || RedisStore.scheduled_travel_time(pair.first, pair.second)] 139 | }.compact 140 | end 141 | end -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = 'http://assets.example.com' 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 38 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = 'wss://example.com/cable' 46 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [ :request_id ] 57 | 58 | # Use a different cache store in production. 59 | config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] } if ENV['REDIS_URL'].present? 60 | config.cache_store = :redis_cache_store, { url: ENV['REDISCLOUD_URL'] } if ENV['REDISCLOUD_URL'].present? 61 | 62 | # Use a real queuing backend for Active Job (and separate queues per environment). 63 | # config.active_job.queue_adapter = :resque 64 | # config.active_job.queue_name_prefix = "goodservice_v2_production" 65 | 66 | config.action_mailer.perform_caching = false 67 | 68 | # Ignore bad email addresses and do not raise email delivery errors. 69 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 70 | # config.action_mailer.raise_delivery_errors = false 71 | 72 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 73 | # the I18n.default_locale when a translation cannot be found). 74 | config.i18n.fallbacks = true 75 | 76 | # Send deprecation notices to registered listeners. 77 | config.active_support.deprecation = :notify 78 | 79 | # Log disallowed deprecations. 80 | config.active_support.disallowed_deprecation = :log 81 | 82 | # Tell Active Support which deprecation messages to disallow. 83 | config.active_support.disallowed_deprecation_warnings = [] 84 | 85 | # Use default logging formatter so that PID and timestamp are not suppressed. 86 | config.log_formatter = ::Logger::Formatter.new 87 | 88 | # Use a different logger for distributed setups. 89 | # require "syslog/logger" 90 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 91 | 92 | if ENV["RAILS_LOG_TO_STDOUT"].present? 93 | logger = ActiveSupport::Logger.new(STDOUT) 94 | logger.formatter = config.log_formatter 95 | config.logger = ActiveSupport::TaggedLogging.new(logger) 96 | end 97 | 98 | # Do not dump schema after migrations. 99 | config.active_record.dump_schema_after_migration = false 100 | 101 | # Inserts middleware to perform automatic connection switching. 102 | # The `database_selector` hash is used to pass options to the DatabaseSelector 103 | # middleware. The `delay` is used to determine how long to wait after a write 104 | # to send a subsequent read to the primary. 105 | # 106 | # The `database_resolver` class is used by the middleware to determine which 107 | # database is appropriate to use based on the time delay. 108 | # 109 | # The `database_resolver_context` class is used by the middleware to set 110 | # timestamps for the last write to the primary. The resolver uses the context 111 | # class timestamps to determine how long to wait before reading from the 112 | # replica. 113 | # 114 | # By default Rails will store a last write timestamp in the session. The 115 | # DatabaseSelector middleware is designed as such you can define your own 116 | # strategy for connection switching and pass that into the middleware through 117 | # these configuration options. 118 | # config.active_record.database_selector = { delay: 2.seconds } 119 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 120 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 121 | end 122 | -------------------------------------------------------------------------------- /app/models/trip.rb: -------------------------------------------------------------------------------- 1 | class Trip 2 | attr_reader :route_id, :direction, :timestamp, :stops, :tracks 3 | attr_accessor :id, :interval, :previous_trip, :schedule, :past_stops, :latest, :is_assigned 4 | 5 | SOUTHBOUND_J_STOPS_TO_OMIT_TRACKING = ["J15", "J16", "J17", "J19", "J20", "J21", "J22"] 6 | NORTHBOUND_J_STOPS_TO_OMIT_TRACKING = ["J16", "J15", "J14", "J13", "J12", "G06", "G05"] 7 | ETA_THRESHOLD_TO_CONSIDER_STOP_MADE_FOR_STOPS_TRACKING_IS_OMITTED = 10 8 | 9 | def initialize(route_id, direction, id, interval, timestamp, trip_update, is_assigned) 10 | @route_id = route_id 11 | @direction = direction 12 | @id = id 13 | @interval = interval 14 | @timestamp = timestamp 15 | stop_time_hash = trip_update.stop_time_update.filter { |update| 16 | (update.arrival&.time || update.departure&.time || 0) > 0 17 | }.to_h {|update| 18 | [update.stop_id[0..2], update.arrival&.time && update.arrival.time > 0 ? update.arrival.time : update.departure&.time] 19 | } 20 | @stops = stop_time_hash 21 | @schedule = stop_time_hash 22 | @tracks = trip_update.stop_time_update.filter { |update| 23 | (update.arrival&.time || update.departure&.time || 0) > 0 24 | }.to_h {|update| 25 | [update.stop_id[0..2], update.nyct_stop_time_update.actual_track.presence || update.nyct_stop_time_update.scheduled_track ] 26 | } 27 | @past_stops = {} 28 | @latest = true 29 | @is_assigned = is_assigned 30 | end 31 | 32 | def similar(trip) 33 | trip.route_id == route_id && 34 | trip.direction == direction && 35 | destination == trip.destination && 36 | (destination_time - trip.destination_time).abs <= 3.minutes.to_i && 37 | (trip.stops.keys - stops.keys).size <= 1 38 | end 39 | 40 | def timed_out?(time_ref: timestamp) 41 | return false if is_assigned 42 | 43 | (time_ref - first_stop_arrival_time) > 5.minutes.to_i 44 | end 45 | 46 | def stop_ids 47 | stops.keys 48 | end 49 | 50 | def previous_stop 51 | past_stops&.keys&.last 52 | end 53 | 54 | def previous_stop_arrival_time 55 | past_stops&.values&.last 56 | end 57 | 58 | def scheduled_previous_stop_arrival_time 59 | schedule ? (schedule[previous_stop] || previous_stop_arrival_time) : previous_stop_arrival_time 60 | end 61 | 62 | def first_stop_arrival_time 63 | stops.values.first 64 | end 65 | 66 | def upcoming_stop(time_ref: timestamp) 67 | upcoming_stops(time_ref: time_ref).first 68 | end 69 | 70 | def upcoming_stop_arrival_time(time_ref: timestamp) 71 | stops[upcoming_stop(time_ref: time_ref)] || time_ref 72 | end 73 | 74 | def scheduled_upcoming_stop_arrival_time 75 | schedule ? (schedule[upcoming_stop] || upcoming_stop_arrival_time) : upcoming_stop_arrival_time 76 | end 77 | 78 | def time_until_upcoming_stop(time_ref: timestamp) 79 | upcoming_stop_arrival_time(time_ref: time_ref) - time_ref 80 | end 81 | 82 | def upcoming_stops(time_ref: timestamp) 83 | upcoming = stops.select { |_, v| v > time_ref } 84 | to_omit = next_stops_to_omit(upcoming) 85 | upcoming.map(&:first) - past_stops.keys - to_omit 86 | end 87 | 88 | def stops_behind(trip) 89 | i = upcoming_stops.index(trip.upcoming_stop) 90 | return [] unless i && i > 0 91 | 92 | upcoming_stops[0..i] 93 | end 94 | 95 | def update_stops_made! 96 | (@past_stops || {}).merge!(stops_made) 97 | end 98 | 99 | def stops_made 100 | return {} unless previous_trip 101 | 102 | previous_trip.stops.select { |stop_id, time| 103 | # Because you'd never know with these data 104 | !upcoming_stops.include?(stop_id) && !past_stops.include?(stop_id) 105 | }.map { |stop_id, time| 106 | [stop_id, [timestamp, time].min] 107 | }.to_h 108 | end 109 | 110 | def time_traveled_between_stops_made 111 | stops_hash = {} 112 | stops_made_hash = stops_made 113 | 114 | return {} unless stops_made_hash.present? 115 | 116 | # add last stop made 117 | if past_stops.present? 118 | i = past_stops.keys.index(stops_made.keys.first) 119 | # filter out stops already made 120 | if i && i > 0 121 | last_stop_id = past_stops.keys[i - 1] 122 | stops_hash[last_stop_id] = past_stops[last_stop_id] 123 | elsif i.nil? 124 | last_stop_id = past_stops.keys.last 125 | stops_hash[last_stop_id] = past_stops[last_stop_id] 126 | end 127 | end 128 | 129 | stops_hash.merge!(stops_made_hash).each_cons(2).to_h do |(a_stop, a_timestamp), (b_stop, b_timestamp)| 130 | ["#{a_stop}-#{b_stop}", b_timestamp - a_timestamp] 131 | end 132 | end 133 | 134 | def time_between_stops(time_limit) 135 | stops.select { |_, time| time <= timestamp + time_limit}.each_cons(2).map { |a, b| ["#{a.first}-#{b.first}", b.last - a.last]}.to_h 136 | end 137 | 138 | def next_stop_time 139 | stops.first&.last 140 | end 141 | 142 | def destination 143 | stops.keys.last 144 | end 145 | 146 | def destination_time 147 | stops.values.last 148 | end 149 | 150 | def schedule_discrepancy 151 | (upcoming_stop_arrival_time - scheduled_upcoming_stop_arrival_time).to_i 152 | end 153 | 154 | def previous_stop_schedule_discrepancy 155 | return 0 unless previous_stop 156 | previous_stop_arrival_time - scheduled_previous_stop_arrival_time 157 | end 158 | 159 | def is_phantom? 160 | !is_assigned && previous_stop.present? 161 | end 162 | 163 | private 164 | 165 | def next_stops_to_omit(upcoming_stop_hash) 166 | return [] unless ["J", "Z"].include?(route_id) && upcoming_stop_hash.size > 1 167 | 168 | omitted_stops_array = SOUTHBOUND_J_STOPS_TO_OMIT_TRACKING 169 | 170 | if direction == 1 171 | omitted_stops_array = NORTHBOUND_J_STOPS_TO_OMIT_TRACKING 172 | end 173 | 174 | return [] unless omitted_stops_array.include?(upcoming_stop_hash.keys.first) 175 | 176 | results = [] 177 | 178 | upcoming_stop_hash.each_cons(2) do |a, b| 179 | break unless omitted_stops_array.include?(a[0]) 180 | if b[1] - a[1] < ETA_THRESHOLD_TO_CONSIDER_STOP_MADE_FOR_STOPS_TRACKING_IS_OMITTED 181 | results << a[0] 182 | end 183 | end 184 | 185 | results 186 | end 187 | end --------------------------------------------------------------------------------