njtransit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/njtransit.md +196 -0
  3. data/.mcp.json.example +12 -0
  4. data/.mcp.json.sample +11 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +87 -0
  7. data/.ruby-version +1 -0
  8. data/CHANGELOG.md +37 -0
  9. data/CLAUDE.md +159 -0
  10. data/CODE_OF_CONDUCT.md +84 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +148 -0
  13. data/Rakefile +12 -0
  14. data/docs/plans/2025-01-24-njtransit-gem-design.md +112 -0
  15. data/docs/plans/2026-01-24-bus-api-design.md +119 -0
  16. data/docs/plans/2026-01-24-gtfs-implementation.md +2216 -0
  17. data/docs/plans/2026-01-24-gtfs-loader-design.md +351 -0
  18. data/docs/superpowers/plans/2026-03-26-dev-infra-and-agent.md +480 -0
  19. data/lefthook.yml +17 -0
  20. data/lib/njtransit/client.rb +291 -0
  21. data/lib/njtransit/configuration.rb +49 -0
  22. data/lib/njtransit/error.rb +50 -0
  23. data/lib/njtransit/gtfs/database.rb +145 -0
  24. data/lib/njtransit/gtfs/importer.rb +124 -0
  25. data/lib/njtransit/gtfs/models/route.rb +59 -0
  26. data/lib/njtransit/gtfs/models/stop.rb +63 -0
  27. data/lib/njtransit/gtfs/queries/routes_between.rb +62 -0
  28. data/lib/njtransit/gtfs/queries/schedule.rb +75 -0
  29. data/lib/njtransit/gtfs.rb +119 -0
  30. data/lib/njtransit/railtie.rb +9 -0
  31. data/lib/njtransit/resources/base.rb +35 -0
  32. data/lib/njtransit/resources/bus/enrichment.rb +105 -0
  33. data/lib/njtransit/resources/bus.rb +95 -0
  34. data/lib/njtransit/resources/bus_gtfs.rb +34 -0
  35. data/lib/njtransit/resources/rail.rb +47 -0
  36. data/lib/njtransit/resources/rail_gtfs.rb +27 -0
  37. data/lib/njtransit/tasks.rb +74 -0
  38. data/lib/njtransit/version.rb +5 -0
  39. data/lib/njtransit.rb +40 -0
  40. data/sig/njtransit.rbs +4 -0
  41. metadata +177 -0
@@ -0,0 +1,2216 @@
1
+ # GTFS Static Data Loader Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add GTFS static data loading with SQLite storage and automatic Bus API enrichment.
6
+
7
+ **Architecture:** Sequel gem for database access, SQLite for persistent storage at XDG-compliant path. GTFS import is a one-time deployment step via Rake task. Bus API methods automatically enrich responses with GTFS data (lat/lon, route names).
8
+
9
+ **Tech Stack:** Ruby 3.2+, Sequel ~> 5.0, SQLite3 ~> 2.0, Rake
10
+
11
+ ---
12
+
13
+ ## Task 1: Add Dependencies
14
+
15
+ **Files:**
16
+ - Modify: `njtransit.gemspec:36-39`
17
+ - Modify: `Gemfile`
18
+
19
+ **Step 1: Add sequel and sqlite3 to gemspec**
20
+
21
+ In `njtransit.gemspec`, add after line 39:
22
+
23
+ ```ruby
24
+ spec.add_dependency "sequel", "~> 5.0"
25
+ spec.add_dependency "sqlite3", "~> 2.0"
26
+ ```
27
+
28
+ **Step 2: Run bundle install**
29
+
30
+ Run: `bundle install`
31
+ Expected: Dependencies installed successfully
32
+
33
+ **Step 3: Commit**
34
+
35
+ ```bash
36
+ git add njtransit.gemspec Gemfile.lock
37
+ git commit -m "feat: add sequel and sqlite3 dependencies for GTFS"
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Task 2: Add GTFSNotImportedError
43
+
44
+ **Files:**
45
+ - Modify: `lib/njtransit/error.rb`
46
+ - Create: `spec/njtransit/gtfs_not_imported_error_spec.rb`
47
+
48
+ **Step 1: Write the failing test**
49
+
50
+ Create `spec/njtransit/gtfs_not_imported_error_spec.rb`:
51
+
52
+ ```ruby
53
+ # frozen_string_literal: true
54
+
55
+ RSpec.describe NJTransit::GTFSNotImportedError do
56
+ describe "#initialize" do
57
+ it "includes the base message" do
58
+ error = described_class.new
59
+ expect(error.message).to include("GTFS data not found")
60
+ end
61
+
62
+ it "includes hint when gtfs_path is provided" do
63
+ error = described_class.new(detected_path: "./bus_data")
64
+ expect(error.message).to include("Detected GTFS files at: ./bus_data")
65
+ expect(error.message).to include("rake njtransit:gtfs:import[./bus_data]")
66
+ end
67
+
68
+ it "excludes hint when no path detected" do
69
+ error = described_class.new
70
+ expect(error.message).not_to include("Detected GTFS files")
71
+ end
72
+ end
73
+
74
+ it "inherits from NJTransit::Error" do
75
+ expect(described_class.superclass).to eq(NJTransit::Error)
76
+ end
77
+ end
78
+ ```
79
+
80
+ **Step 2: Run test to verify it fails**
81
+
82
+ Run: `bundle exec rspec spec/njtransit/gtfs_not_imported_error_spec.rb -v`
83
+ Expected: FAIL with "uninitialized constant NJTransit::GTFSNotImportedError"
84
+
85
+ **Step 3: Write minimal implementation**
86
+
87
+ Add to `lib/njtransit/error.rb` after line 37 (before final `end`):
88
+
89
+ ```ruby
90
+ # GTFS not imported
91
+ class GTFSNotImportedError < Error
92
+ def initialize(detected_path: nil)
93
+ message = "GTFS data not found. Run: rake njtransit:gtfs:import[/path/to/bus_data]"
94
+ if detected_path
95
+ message += "\n\nDetected GTFS files at: #{detected_path}"
96
+ message += "\nHint: rake njtransit:gtfs:import[#{detected_path}]"
97
+ end
98
+ super(message)
99
+ end
100
+ end
101
+ ```
102
+
103
+ **Step 4: Run test to verify it passes**
104
+
105
+ Run: `bundle exec rspec spec/njtransit/gtfs_not_imported_error_spec.rb -v`
106
+ Expected: PASS (3 examples, 0 failures)
107
+
108
+ **Step 5: Commit**
109
+
110
+ ```bash
111
+ git add lib/njtransit/error.rb spec/njtransit/gtfs_not_imported_error_spec.rb
112
+ git commit -m "feat: add GTFSNotImportedError with auto-detection hint"
113
+ ```
114
+
115
+ ---
116
+
117
+ ## Task 3: Add Configuration for GTFS Database Path
118
+
119
+ **Files:**
120
+ - Modify: `lib/njtransit/configuration.rb`
121
+ - Modify: `spec/njtransit/configuration_spec.rb`
122
+
123
+ **Step 1: Write the failing test**
124
+
125
+ Add to `spec/njtransit/configuration_spec.rb`:
126
+
127
+ ```ruby
128
+ describe "#gtfs_database_path" do
129
+ it "defaults to XDG_DATA_HOME if set" do
130
+ allow(ENV).to receive(:fetch).and_call_original
131
+ allow(ENV).to receive(:fetch).with("NJTRANSIT_GTFS_DATABASE_PATH", anything).and_return(nil)
132
+ allow(ENV).to receive(:[]).with("XDG_DATA_HOME").and_return("/custom/xdg")
133
+
134
+ config = described_class.new
135
+ expect(config.gtfs_database_path).to eq("/custom/xdg/njtransit/gtfs.sqlite3")
136
+ end
137
+
138
+ it "defaults to ~/.local/share/njtransit when XDG_DATA_HOME not set" do
139
+ allow(ENV).to receive(:fetch).and_call_original
140
+ allow(ENV).to receive(:fetch).with("NJTRANSIT_GTFS_DATABASE_PATH", anything).and_return(nil)
141
+ allow(ENV).to receive(:[]).with("XDG_DATA_HOME").and_return(nil)
142
+
143
+ config = described_class.new
144
+ expect(config.gtfs_database_path).to eq(File.expand_path("~/.local/share/njtransit/gtfs.sqlite3"))
145
+ end
146
+
147
+ it "can be overridden via environment variable" do
148
+ allow(ENV).to receive(:fetch).and_call_original
149
+ allow(ENV).to receive(:fetch).with("NJTRANSIT_GTFS_DATABASE_PATH", anything).and_return("/custom/path/gtfs.db")
150
+
151
+ config = described_class.new
152
+ expect(config.gtfs_database_path).to eq("/custom/path/gtfs.db")
153
+ end
154
+
155
+ it "can be set directly" do
156
+ config = described_class.new
157
+ config.gtfs_database_path = "/my/path/gtfs.sqlite3"
158
+ expect(config.gtfs_database_path).to eq("/my/path/gtfs.sqlite3")
159
+ end
160
+ end
161
+ ```
162
+
163
+ **Step 2: Run test to verify it fails**
164
+
165
+ Run: `bundle exec rspec spec/njtransit/configuration_spec.rb -v`
166
+ Expected: FAIL with "undefined method `gtfs_database_path'"
167
+
168
+ **Step 3: Write minimal implementation**
169
+
170
+ In `lib/njtransit/configuration.rb`:
171
+
172
+ Add to attr_accessor on line 9:
173
+ ```ruby
174
+ attr_accessor :username, :password, :base_url, :timeout, :gtfs_database_path
175
+ ```
176
+
177
+ Add to initialize method after line 17:
178
+ ```ruby
179
+ @gtfs_database_path = ENV.fetch("NJTRANSIT_GTFS_DATABASE_PATH", nil) || default_gtfs_database_path
180
+ ```
181
+
182
+ Add private method before the final `end`:
183
+ ```ruby
184
+ private
185
+
186
+ def default_gtfs_database_path
187
+ base = ENV["XDG_DATA_HOME"] || File.expand_path("~/.local/share")
188
+ File.join(base, "njtransit", "gtfs.sqlite3")
189
+ end
190
+ ```
191
+
192
+ **Step 4: Run test to verify it passes**
193
+
194
+ Run: `bundle exec rspec spec/njtransit/configuration_spec.rb -v`
195
+ Expected: PASS
196
+
197
+ **Step 5: Commit**
198
+
199
+ ```bash
200
+ git add lib/njtransit/configuration.rb spec/njtransit/configuration_spec.rb
201
+ git commit -m "feat: add gtfs_database_path configuration with XDG support"
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Task 4: Create Test Fixtures
207
+
208
+ **Files:**
209
+ - Create: `spec/fixtures/gtfs/agency.txt`
210
+ - Create: `spec/fixtures/gtfs/routes.txt`
211
+ - Create: `spec/fixtures/gtfs/stops.txt`
212
+ - Create: `spec/fixtures/gtfs/trips.txt`
213
+ - Create: `spec/fixtures/gtfs/stop_times.txt`
214
+ - Create: `spec/fixtures/gtfs/calendar_dates.txt`
215
+ - Create: `spec/fixtures/gtfs/shapes.txt`
216
+
217
+ **Step 1: Create agency.txt**
218
+
219
+ ```csv
220
+ agency_id,agency_name,agency_url,agency_timezone
221
+ NJB,NJ TRANSIT BUS,http://www.njtransit.com,America/New_York
222
+ ```
223
+
224
+ **Step 2: Create routes.txt**
225
+
226
+ ```csv
227
+ route_id,agency_id,route_short_name,route_long_name,route_type,route_color
228
+ 1,NJB,1,Newark - Jersey City,3,000000
229
+ 2,NJB,10,Bloomfield - Newark,3,000000
230
+ 3,NJB,197,Willowbrook - Port Authority,3,FF0000
231
+ ```
232
+
233
+ **Step 3: Create stops.txt**
234
+
235
+ ```csv
236
+ stop_id,stop_code,stop_name,stop_desc,stop_lat,stop_lon,zone_id
237
+ 1,WBRK,WILLOWBROOK MALL,,40.852300,-74.256700,NB
238
+ 2,PABT,PORT AUTHORITY BUS TERMINAL,,40.756600,-73.990900,NY
239
+ 3,21681,MAIN ST AT CENTER,,40.123400,-74.567800,NB
240
+ 4,21682,BROAD ST AT MARKET,,40.234500,-74.678900,NK
241
+ 5,21683,CENTRAL AVE AT 1ST,,40.345600,-74.789000,JC
242
+ ```
243
+
244
+ **Step 4: Create trips.txt**
245
+
246
+ ```csv
247
+ route_id,service_id,trip_id,trip_headsign,direction_id,block_id,shape_id
248
+ 3,1,100,197 PORT AUTHORITY,0,BLK001,1
249
+ 3,1,101,197 PORT AUTHORITY,0,BLK002,1
250
+ 3,1,102,197 WILLOWBROOK,1,BLK003,2
251
+ 3,2,103,197 PORT AUTHORITY,0,BLK004,1
252
+ 1,1,200,1 JERSEY CITY,0,BLK010,3
253
+ ```
254
+
255
+ **Step 5: Create stop_times.txt**
256
+
257
+ ```csv
258
+ trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type
259
+ 100,06:00:00,06:00:00,1,1,0,0
260
+ 100,06:45:00,06:45:00,2,2,0,0
261
+ 101,07:00:00,07:00:00,1,1,0,0
262
+ 101,07:45:00,07:45:00,2,2,0,0
263
+ 102,08:00:00,08:00:00,2,1,0,0
264
+ 102,08:45:00,08:45:00,1,2,0,0
265
+ 103,09:00:00,09:00:00,1,1,0,0
266
+ 103,09:45:00,09:45:00,2,2,0,0
267
+ 200,05:30:00,05:30:00,4,1,0,0
268
+ 200,06:00:00,06:00:00,5,2,0,0
269
+ ```
270
+
271
+ **Step 6: Create calendar_dates.txt**
272
+
273
+ ```csv
274
+ service_id,date,exception_type
275
+ 1,20260124,1
276
+ 1,20260125,1
277
+ 2,20260126,1
278
+ ```
279
+
280
+ **Step 7: Create shapes.txt**
281
+
282
+ ```csv
283
+ shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence
284
+ 1,40.852300,-74.256700,1
285
+ 1,40.800000,-74.100000,2
286
+ 1,40.756600,-73.990900,3
287
+ 2,40.756600,-73.990900,1
288
+ 2,40.800000,-74.100000,2
289
+ 2,40.852300,-74.256700,3
290
+ ```
291
+
292
+ **Step 8: Commit**
293
+
294
+ ```bash
295
+ git add spec/fixtures/gtfs/
296
+ git commit -m "test: add GTFS fixture files for testing"
297
+ ```
298
+
299
+ ---
300
+
301
+ ## Task 5: Create GTFS Database Module
302
+
303
+ **Files:**
304
+ - Create: `lib/njtransit/gtfs/database.rb`
305
+ - Create: `spec/njtransit/gtfs/database_spec.rb`
306
+
307
+ **Step 1: Write the failing test**
308
+
309
+ Create `spec/njtransit/gtfs/database_spec.rb`:
310
+
311
+ ```ruby
312
+ # frozen_string_literal: true
313
+
314
+ require "fileutils"
315
+
316
+ RSpec.describe NJTransit::GTFS::Database do
317
+ let(:test_db_path) { "tmp/test_gtfs.sqlite3" }
318
+
319
+ before do
320
+ FileUtils.mkdir_p("tmp")
321
+ FileUtils.rm_f(test_db_path)
322
+ end
323
+
324
+ after do
325
+ described_class.disconnect
326
+ FileUtils.rm_f(test_db_path)
327
+ end
328
+
329
+ describe ".connection" do
330
+ it "creates a Sequel SQLite connection" do
331
+ conn = described_class.connection(test_db_path)
332
+ expect(conn).to be_a(Sequel::SQLite::Database)
333
+ end
334
+
335
+ it "creates the database file" do
336
+ described_class.connection(test_db_path)
337
+ expect(File.exist?(test_db_path)).to be true
338
+ end
339
+
340
+ it "returns the same connection on subsequent calls" do
341
+ conn1 = described_class.connection(test_db_path)
342
+ conn2 = described_class.connection(test_db_path)
343
+ expect(conn1).to be(conn2)
344
+ end
345
+ end
346
+
347
+ describe ".setup_schema!" do
348
+ before { described_class.connection(test_db_path) }
349
+
350
+ it "creates the agencies table" do
351
+ described_class.setup_schema!
352
+ expect(described_class.connection(test_db_path).table_exists?(:agencies)).to be true
353
+ end
354
+
355
+ it "creates the routes table" do
356
+ described_class.setup_schema!
357
+ expect(described_class.connection(test_db_path).table_exists?(:routes)).to be true
358
+ end
359
+
360
+ it "creates the stops table" do
361
+ described_class.setup_schema!
362
+ expect(described_class.connection(test_db_path).table_exists?(:stops)).to be true
363
+ end
364
+
365
+ it "creates the trips table" do
366
+ described_class.setup_schema!
367
+ expect(described_class.connection(test_db_path).table_exists?(:trips)).to be true
368
+ end
369
+
370
+ it "creates the stop_times table" do
371
+ described_class.setup_schema!
372
+ expect(described_class.connection(test_db_path).table_exists?(:stop_times)).to be true
373
+ end
374
+
375
+ it "creates the calendar_dates table" do
376
+ described_class.setup_schema!
377
+ expect(described_class.connection(test_db_path).table_exists?(:calendar_dates)).to be true
378
+ end
379
+
380
+ it "creates the shapes table" do
381
+ described_class.setup_schema!
382
+ expect(described_class.connection(test_db_path).table_exists?(:shapes)).to be true
383
+ end
384
+ end
385
+
386
+ describe ".exists?" do
387
+ it "returns false when database does not exist" do
388
+ expect(described_class.exists?(test_db_path)).to be false
389
+ end
390
+
391
+ it "returns true when database exists" do
392
+ described_class.connection(test_db_path)
393
+ described_class.setup_schema!
394
+ described_class.disconnect
395
+ expect(described_class.exists?(test_db_path)).to be true
396
+ end
397
+ end
398
+ end
399
+ ```
400
+
401
+ **Step 2: Run test to verify it fails**
402
+
403
+ Run: `bundle exec rspec spec/njtransit/gtfs/database_spec.rb -v`
404
+ Expected: FAIL with "uninitialized constant NJTransit::GTFS"
405
+
406
+ **Step 3: Create the directory structure**
407
+
408
+ Run: `mkdir -p lib/njtransit/gtfs`
409
+
410
+ **Step 4: Write implementation**
411
+
412
+ Create `lib/njtransit/gtfs/database.rb`:
413
+
414
+ ```ruby
415
+ # frozen_string_literal: true
416
+
417
+ require "sequel"
418
+ require "fileutils"
419
+
420
+ module NJTransit
421
+ module GTFS
422
+ module Database
423
+ class << self
424
+ def connection(path = nil)
425
+ @path = path if path
426
+ @connection ||= begin
427
+ FileUtils.mkdir_p(File.dirname(@path))
428
+ Sequel.sqlite(@path)
429
+ end
430
+ end
431
+
432
+ def disconnect
433
+ @connection&.disconnect
434
+ @connection = nil
435
+ end
436
+
437
+ def exists?(path)
438
+ return false unless File.exist?(path)
439
+
440
+ db = Sequel.sqlite(path)
441
+ db.table_exists?(:agencies) && db.table_exists?(:stops)
442
+ rescue StandardError
443
+ false
444
+ ensure
445
+ db&.disconnect
446
+ end
447
+
448
+ def setup_schema!
449
+ db = connection
450
+
451
+ db.create_table?(:agencies) do
452
+ String :agency_id, primary_key: true
453
+ String :agency_name
454
+ String :agency_url
455
+ String :agency_timezone
456
+ end
457
+
458
+ db.create_table?(:routes) do
459
+ String :route_id, primary_key: true
460
+ String :agency_id
461
+ String :route_short_name
462
+ String :route_long_name
463
+ Integer :route_type
464
+ String :route_color
465
+ index :route_short_name
466
+ end
467
+
468
+ db.create_table?(:stops) do
469
+ String :stop_id, primary_key: true
470
+ String :stop_code
471
+ String :stop_name
472
+ Float :stop_lat
473
+ Float :stop_lon
474
+ String :zone_id
475
+ index :stop_code
476
+ end
477
+
478
+ db.create_table?(:trips) do
479
+ String :trip_id, primary_key: true
480
+ String :route_id
481
+ String :service_id
482
+ String :trip_headsign
483
+ Integer :direction_id
484
+ String :shape_id
485
+ index :route_id
486
+ index :service_id
487
+ end
488
+
489
+ db.create_table?(:stop_times) do
490
+ primary_key :id
491
+ String :trip_id
492
+ String :stop_id
493
+ String :arrival_time
494
+ String :departure_time
495
+ Integer :stop_sequence
496
+ index :trip_id
497
+ index :stop_id
498
+ end
499
+
500
+ db.create_table?(:calendar_dates) do
501
+ primary_key :id
502
+ String :service_id
503
+ String :date
504
+ Integer :exception_type
505
+ index [:service_id, :date]
506
+ end
507
+
508
+ db.create_table?(:shapes) do
509
+ primary_key :id
510
+ String :shape_id
511
+ Float :shape_pt_lat
512
+ Float :shape_pt_lon
513
+ Integer :shape_pt_sequence
514
+ index :shape_id
515
+ end
516
+
517
+ db.create_table?(:import_metadata) do
518
+ primary_key :id
519
+ DateTime :imported_at
520
+ String :source_path
521
+ end
522
+ end
523
+
524
+ def clear!
525
+ db = connection
526
+ %i[agencies routes stops trips stop_times calendar_dates shapes import_metadata].each do |table|
527
+ db.drop_table?(table)
528
+ end
529
+ end
530
+ end
531
+ end
532
+ end
533
+ end
534
+ ```
535
+
536
+ **Step 5: Run test to verify it passes**
537
+
538
+ Run: `bundle exec rspec spec/njtransit/gtfs/database_spec.rb -v`
539
+ Expected: PASS
540
+
541
+ **Step 6: Commit**
542
+
543
+ ```bash
544
+ git add lib/njtransit/gtfs/database.rb spec/njtransit/gtfs/database_spec.rb
545
+ git commit -m "feat: add GTFS database module with schema setup"
546
+ ```
547
+
548
+ ---
549
+
550
+ ## Task 6: Create GTFS Importer
551
+
552
+ **Files:**
553
+ - Create: `lib/njtransit/gtfs/importer.rb`
554
+ - Create: `spec/njtransit/gtfs/importer_spec.rb`
555
+
556
+ **Step 1: Write the failing test**
557
+
558
+ Create `spec/njtransit/gtfs/importer_spec.rb`:
559
+
560
+ ```ruby
561
+ # frozen_string_literal: true
562
+
563
+ require "fileutils"
564
+
565
+ RSpec.describe NJTransit::GTFS::Importer do
566
+ let(:fixtures_path) { "spec/fixtures/gtfs" }
567
+ let(:test_db_path) { "tmp/test_import.sqlite3" }
568
+
569
+ before do
570
+ FileUtils.mkdir_p("tmp")
571
+ FileUtils.rm_f(test_db_path)
572
+ end
573
+
574
+ after do
575
+ NJTransit::GTFS::Database.disconnect
576
+ FileUtils.rm_f(test_db_path)
577
+ end
578
+
579
+ describe "#import" do
580
+ subject(:importer) { described_class.new(fixtures_path, test_db_path) }
581
+
582
+ it "imports agencies" do
583
+ importer.import
584
+ db = NJTransit::GTFS::Database.connection(test_db_path)
585
+ expect(db[:agencies].count).to eq(1)
586
+ end
587
+
588
+ it "imports routes" do
589
+ importer.import
590
+ db = NJTransit::GTFS::Database.connection(test_db_path)
591
+ expect(db[:routes].count).to eq(3)
592
+ end
593
+
594
+ it "imports stops" do
595
+ importer.import
596
+ db = NJTransit::GTFS::Database.connection(test_db_path)
597
+ expect(db[:stops].count).to eq(5)
598
+ end
599
+
600
+ it "imports trips" do
601
+ importer.import
602
+ db = NJTransit::GTFS::Database.connection(test_db_path)
603
+ expect(db[:trips].count).to eq(5)
604
+ end
605
+
606
+ it "imports stop_times" do
607
+ importer.import
608
+ db = NJTransit::GTFS::Database.connection(test_db_path)
609
+ expect(db[:stop_times].count).to eq(10)
610
+ end
611
+
612
+ it "imports calendar_dates" do
613
+ importer.import
614
+ db = NJTransit::GTFS::Database.connection(test_db_path)
615
+ expect(db[:calendar_dates].count).to eq(3)
616
+ end
617
+
618
+ it "imports shapes" do
619
+ importer.import
620
+ db = NJTransit::GTFS::Database.connection(test_db_path)
621
+ expect(db[:shapes].count).to eq(6)
622
+ end
623
+
624
+ it "records import metadata" do
625
+ importer.import
626
+ db = NJTransit::GTFS::Database.connection(test_db_path)
627
+ metadata = db[:import_metadata].first
628
+ expect(metadata[:source_path]).to eq(fixtures_path)
629
+ expect(metadata[:imported_at]).not_to be_nil
630
+ end
631
+
632
+ it "clears existing data when force: true" do
633
+ importer.import
634
+ # Import again with force
635
+ described_class.new(fixtures_path, test_db_path).import(force: true)
636
+ db = NJTransit::GTFS::Database.connection(test_db_path)
637
+ expect(db[:import_metadata].count).to eq(1)
638
+ end
639
+
640
+ it "raises error if database exists without force" do
641
+ importer.import
642
+ NJTransit::GTFS::Database.disconnect
643
+ expect {
644
+ described_class.new(fixtures_path, test_db_path).import
645
+ }.to raise_error(NJTransit::Error, /already exists/)
646
+ end
647
+ end
648
+
649
+ describe "#valid_gtfs_directory?" do
650
+ it "returns true for valid GTFS directory" do
651
+ importer = described_class.new(fixtures_path, test_db_path)
652
+ expect(importer.valid_gtfs_directory?).to be true
653
+ end
654
+
655
+ it "returns false for invalid directory" do
656
+ importer = described_class.new("/nonexistent", test_db_path)
657
+ expect(importer.valid_gtfs_directory?).to be false
658
+ end
659
+ end
660
+ end
661
+ ```
662
+
663
+ **Step 2: Run test to verify it fails**
664
+
665
+ Run: `bundle exec rspec spec/njtransit/gtfs/importer_spec.rb -v`
666
+ Expected: FAIL with "uninitialized constant NJTransit::GTFS::Importer"
667
+
668
+ **Step 3: Write implementation**
669
+
670
+ Create `lib/njtransit/gtfs/importer.rb`:
671
+
672
+ ```ruby
673
+ # frozen_string_literal: true
674
+
675
+ require "csv"
676
+
677
+ module NJTransit
678
+ module GTFS
679
+ class Importer
680
+ REQUIRED_FILES = %w[agency.txt routes.txt stops.txt].freeze
681
+ OPTIONAL_FILES = %w[trips.txt stop_times.txt calendar_dates.txt shapes.txt].freeze
682
+
683
+ attr_reader :source_path, :db_path
684
+
685
+ def initialize(source_path, db_path)
686
+ @source_path = source_path
687
+ @db_path = db_path
688
+ end
689
+
690
+ def import(force: false)
691
+ if Database.exists?(db_path) && !force
692
+ raise NJTransit::Error, "GTFS database already exists at #{db_path}. Use force: true to reimport."
693
+ end
694
+
695
+ Database.disconnect
696
+ FileUtils.rm_f(db_path) if force
697
+
698
+ Database.connection(db_path)
699
+ Database.setup_schema!
700
+
701
+ import_agencies
702
+ import_routes
703
+ import_stops
704
+ import_trips
705
+ import_stop_times
706
+ import_calendar_dates
707
+ import_shapes
708
+ record_metadata
709
+ end
710
+
711
+ def valid_gtfs_directory?
712
+ return false unless File.directory?(source_path)
713
+
714
+ REQUIRED_FILES.all? { |f| File.exist?(File.join(source_path, f)) }
715
+ end
716
+
717
+ private
718
+
719
+ def import_agencies
720
+ import_csv("agency.txt", :agencies) do |row|
721
+ {
722
+ agency_id: row["agency_id"],
723
+ agency_name: row["agency_name"],
724
+ agency_url: row["agency_url"],
725
+ agency_timezone: row["agency_timezone"]
726
+ }
727
+ end
728
+ end
729
+
730
+ def import_routes
731
+ import_csv("routes.txt", :routes) do |row|
732
+ {
733
+ route_id: row["route_id"],
734
+ agency_id: row["agency_id"],
735
+ route_short_name: row["route_short_name"],
736
+ route_long_name: row["route_long_name"],
737
+ route_type: row["route_type"]&.to_i,
738
+ route_color: row["route_color"]
739
+ }
740
+ end
741
+ end
742
+
743
+ def import_stops
744
+ import_csv("stops.txt", :stops) do |row|
745
+ {
746
+ stop_id: row["stop_id"],
747
+ stop_code: row["stop_code"],
748
+ stop_name: row["stop_name"],
749
+ stop_lat: row["stop_lat"]&.to_f,
750
+ stop_lon: row["stop_lon"]&.to_f,
751
+ zone_id: row["zone_id"]
752
+ }
753
+ end
754
+ end
755
+
756
+ def import_trips
757
+ import_csv("trips.txt", :trips) do |row|
758
+ {
759
+ trip_id: row["trip_id"],
760
+ route_id: row["route_id"],
761
+ service_id: row["service_id"],
762
+ trip_headsign: row["trip_headsign"],
763
+ direction_id: row["direction_id"]&.to_i,
764
+ shape_id: row["shape_id"]
765
+ }
766
+ end
767
+ end
768
+
769
+ def import_stop_times
770
+ import_csv("stop_times.txt", :stop_times, batch_size: 10_000) do |row|
771
+ {
772
+ trip_id: row["trip_id"],
773
+ stop_id: row["stop_id"],
774
+ arrival_time: row["arrival_time"],
775
+ departure_time: row["departure_time"],
776
+ stop_sequence: row["stop_sequence"]&.to_i
777
+ }
778
+ end
779
+ end
780
+
781
+ def import_calendar_dates
782
+ import_csv("calendar_dates.txt", :calendar_dates) do |row|
783
+ {
784
+ service_id: row["service_id"],
785
+ date: row["date"],
786
+ exception_type: row["exception_type"]&.to_i
787
+ }
788
+ end
789
+ end
790
+
791
+ def import_shapes
792
+ import_csv("shapes.txt", :shapes, batch_size: 50_000) do |row|
793
+ {
794
+ shape_id: row["shape_id"],
795
+ shape_pt_lat: row["shape_pt_lat"]&.to_f,
796
+ shape_pt_lon: row["shape_pt_lon"]&.to_f,
797
+ shape_pt_sequence: row["shape_pt_sequence"]&.to_i
798
+ }
799
+ end
800
+ end
801
+
802
+ def import_csv(filename, table, batch_size: 1000)
803
+ path = File.join(source_path, filename)
804
+ return unless File.exist?(path)
805
+
806
+ db = Database.connection
807
+ batch = []
808
+
809
+ CSV.foreach(path, headers: true) do |row|
810
+ batch << yield(row)
811
+
812
+ if batch.size >= batch_size
813
+ db[table].multi_insert(batch)
814
+ batch.clear
815
+ end
816
+ end
817
+
818
+ db[table].multi_insert(batch) unless batch.empty?
819
+ end
820
+
821
+ def record_metadata
822
+ Database.connection[:import_metadata].insert(
823
+ imported_at: Time.now,
824
+ source_path: source_path
825
+ )
826
+ end
827
+ end
828
+ end
829
+ end
830
+ ```
831
+
832
+ **Step 4: Run test to verify it passes**
833
+
834
+ Run: `bundle exec rspec spec/njtransit/gtfs/importer_spec.rb -v`
835
+ Expected: PASS
836
+
837
+ **Step 5: Commit**
838
+
839
+ ```bash
840
+ git add lib/njtransit/gtfs/importer.rb spec/njtransit/gtfs/importer_spec.rb
841
+ git commit -m "feat: add GTFS importer with batch CSV parsing"
842
+ ```
843
+
844
+ ---
845
+
846
+ ## Task 7: Create GTFS Models (Stop, Route)
847
+
848
+ **Files:**
849
+ - Create: `lib/njtransit/gtfs/models/stop.rb`
850
+ - Create: `lib/njtransit/gtfs/models/route.rb`
851
+ - Create: `spec/njtransit/gtfs/models/stop_spec.rb`
852
+ - Create: `spec/njtransit/gtfs/models/route_spec.rb`
853
+
854
+ **Step 1: Write the failing test for Stop**
855
+
856
+ Create `spec/njtransit/gtfs/models/stop_spec.rb`:
857
+
858
+ ```ruby
859
+ # frozen_string_literal: true
860
+
861
+ require "fileutils"
862
+
863
+ RSpec.describe NJTransit::GTFS::Models::Stop do
864
+ let(:fixtures_path) { "spec/fixtures/gtfs" }
865
+ let(:test_db_path) { "tmp/test_models.sqlite3" }
866
+
867
+ before(:all) do
868
+ FileUtils.mkdir_p("tmp")
869
+ FileUtils.rm_f("tmp/test_models.sqlite3")
870
+ NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_models.sqlite3").import
871
+ end
872
+
873
+ after(:all) do
874
+ NJTransit::GTFS::Database.disconnect
875
+ FileUtils.rm_f("tmp/test_models.sqlite3")
876
+ end
877
+
878
+ before do
879
+ described_class.db = NJTransit::GTFS::Database.connection(test_db_path)
880
+ end
881
+
882
+ describe ".all" do
883
+ it "returns all stops" do
884
+ stops = described_class.all
885
+ expect(stops.count).to eq(5)
886
+ end
887
+ end
888
+
889
+ describe ".find" do
890
+ it "finds stop by stop_id" do
891
+ stop = described_class.find("1")
892
+ expect(stop.stop_code).to eq("WBRK")
893
+ end
894
+
895
+ it "returns nil when not found" do
896
+ stop = described_class.find("nonexistent")
897
+ expect(stop).to be_nil
898
+ end
899
+ end
900
+
901
+ describe ".find_by_code" do
902
+ it "finds stop by stop_code" do
903
+ stop = described_class.find_by_code("WBRK")
904
+ expect(stop.stop_name).to eq("WILLOWBROOK MALL")
905
+ end
906
+
907
+ it "returns nil when not found" do
908
+ stop = described_class.find_by_code("XXXXX")
909
+ expect(stop).to be_nil
910
+ end
911
+ end
912
+
913
+ describe ".where" do
914
+ it "filters by attributes" do
915
+ stops = described_class.where(zone_id: "NB")
916
+ expect(stops.count).to eq(2)
917
+ end
918
+ end
919
+
920
+ describe "instance methods" do
921
+ let(:stop) { described_class.find_by_code("WBRK") }
922
+
923
+ it "has lat accessor" do
924
+ expect(stop.lat).to eq(40.8523)
925
+ end
926
+
927
+ it "has lon accessor" do
928
+ expect(stop.lon).to eq(-74.2567)
929
+ end
930
+
931
+ it "converts to hash" do
932
+ hash = stop.to_h
933
+ expect(hash[:stop_id]).to eq("1")
934
+ expect(hash[:stop_code]).to eq("WBRK")
935
+ expect(hash[:lat]).to eq(40.8523)
936
+ expect(hash[:lon]).to eq(-74.2567)
937
+ end
938
+ end
939
+ end
940
+ ```
941
+
942
+ **Step 2: Run test to verify it fails**
943
+
944
+ Run: `bundle exec rspec spec/njtransit/gtfs/models/stop_spec.rb -v`
945
+ Expected: FAIL with "uninitialized constant"
946
+
947
+ **Step 3: Create directories**
948
+
949
+ Run: `mkdir -p lib/njtransit/gtfs/models spec/njtransit/gtfs/models`
950
+
951
+ **Step 4: Write Stop implementation**
952
+
953
+ Create `lib/njtransit/gtfs/models/stop.rb`:
954
+
955
+ ```ruby
956
+ # frozen_string_literal: true
957
+
958
+ module NJTransit
959
+ module GTFS
960
+ module Models
961
+ class Stop
962
+ class << self
963
+ attr_accessor :db
964
+
965
+ def all
966
+ db[:stops].all.map { |row| new(row) }
967
+ end
968
+
969
+ def find(stop_id)
970
+ row = db[:stops].where(stop_id: stop_id).first
971
+ row ? new(row) : nil
972
+ end
973
+
974
+ def find_by_code(stop_code)
975
+ row = db[:stops].where(stop_code: stop_code).first
976
+ row ? new(row) : nil
977
+ end
978
+
979
+ def where(conditions)
980
+ db[:stops].where(conditions).all.map { |row| new(row) }
981
+ end
982
+ end
983
+
984
+ attr_reader :stop_id, :stop_code, :stop_name, :stop_lat, :stop_lon, :zone_id
985
+
986
+ def initialize(attributes)
987
+ @stop_id = attributes[:stop_id]
988
+ @stop_code = attributes[:stop_code]
989
+ @stop_name = attributes[:stop_name]
990
+ @stop_lat = attributes[:stop_lat]
991
+ @stop_lon = attributes[:stop_lon]
992
+ @zone_id = attributes[:zone_id]
993
+ end
994
+
995
+ def lat
996
+ stop_lat
997
+ end
998
+
999
+ def lon
1000
+ stop_lon
1001
+ end
1002
+
1003
+ def to_h
1004
+ {
1005
+ stop_id: stop_id,
1006
+ stop_code: stop_code,
1007
+ stop_name: stop_name,
1008
+ stop_lat: stop_lat,
1009
+ stop_lon: stop_lon,
1010
+ lat: lat,
1011
+ lon: lon,
1012
+ zone_id: zone_id
1013
+ }
1014
+ end
1015
+ end
1016
+ end
1017
+ end
1018
+ end
1019
+ ```
1020
+
1021
+ **Step 5: Write the failing test for Route**
1022
+
1023
+ Create `spec/njtransit/gtfs/models/route_spec.rb`:
1024
+
1025
+ ```ruby
1026
+ # frozen_string_literal: true
1027
+
1028
+ require "fileutils"
1029
+
1030
+ RSpec.describe NJTransit::GTFS::Models::Route do
1031
+ let(:test_db_path) { "tmp/test_models.sqlite3" }
1032
+
1033
+ before(:all) do
1034
+ FileUtils.mkdir_p("tmp")
1035
+ FileUtils.rm_f("tmp/test_models.sqlite3")
1036
+ NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_models.sqlite3").import
1037
+ end
1038
+
1039
+ after(:all) do
1040
+ NJTransit::GTFS::Database.disconnect
1041
+ FileUtils.rm_f("tmp/test_models.sqlite3")
1042
+ end
1043
+
1044
+ before do
1045
+ described_class.db = NJTransit::GTFS::Database.connection(test_db_path)
1046
+ end
1047
+
1048
+ describe ".all" do
1049
+ it "returns all routes" do
1050
+ routes = described_class.all
1051
+ expect(routes.count).to eq(3)
1052
+ end
1053
+ end
1054
+
1055
+ describe ".find" do
1056
+ it "finds route by route_id" do
1057
+ route = described_class.find("3")
1058
+ expect(route.route_short_name).to eq("197")
1059
+ end
1060
+
1061
+ it "finds route by short_name" do
1062
+ route = described_class.find("197")
1063
+ expect(route.route_id).to eq("3")
1064
+ end
1065
+
1066
+ it "returns nil when not found" do
1067
+ route = described_class.find("nonexistent")
1068
+ expect(route).to be_nil
1069
+ end
1070
+ end
1071
+
1072
+ describe "instance methods" do
1073
+ let(:route) { described_class.find("197") }
1074
+
1075
+ it "has short_name accessor" do
1076
+ expect(route.short_name).to eq("197")
1077
+ end
1078
+
1079
+ it "has long_name accessor" do
1080
+ expect(route.long_name).to eq("Willowbrook - Port Authority")
1081
+ end
1082
+
1083
+ it "converts to hash" do
1084
+ hash = route.to_h
1085
+ expect(hash[:route_id]).to eq("3")
1086
+ expect(hash[:short_name]).to eq("197")
1087
+ end
1088
+ end
1089
+ end
1090
+ ```
1091
+
1092
+ **Step 6: Write Route implementation**
1093
+
1094
+ Create `lib/njtransit/gtfs/models/route.rb`:
1095
+
1096
+ ```ruby
1097
+ # frozen_string_literal: true
1098
+
1099
+ module NJTransit
1100
+ module GTFS
1101
+ module Models
1102
+ class Route
1103
+ class << self
1104
+ attr_accessor :db
1105
+
1106
+ def all
1107
+ db[:routes].all.map { |row| new(row) }
1108
+ end
1109
+
1110
+ def find(identifier)
1111
+ row = db[:routes].where(route_id: identifier).first
1112
+ row ||= db[:routes].where(route_short_name: identifier).first
1113
+ row ? new(row) : nil
1114
+ end
1115
+
1116
+ def where(conditions)
1117
+ db[:routes].where(conditions).all.map { |row| new(row) }
1118
+ end
1119
+ end
1120
+
1121
+ attr_reader :route_id, :agency_id, :route_short_name, :route_long_name, :route_type, :route_color
1122
+
1123
+ def initialize(attributes)
1124
+ @route_id = attributes[:route_id]
1125
+ @agency_id = attributes[:agency_id]
1126
+ @route_short_name = attributes[:route_short_name]
1127
+ @route_long_name = attributes[:route_long_name]
1128
+ @route_type = attributes[:route_type]
1129
+ @route_color = attributes[:route_color]
1130
+ end
1131
+
1132
+ def short_name
1133
+ route_short_name
1134
+ end
1135
+
1136
+ def long_name
1137
+ route_long_name
1138
+ end
1139
+
1140
+ def to_h
1141
+ {
1142
+ route_id: route_id,
1143
+ agency_id: agency_id,
1144
+ route_short_name: route_short_name,
1145
+ route_long_name: route_long_name,
1146
+ short_name: short_name,
1147
+ long_name: long_name,
1148
+ route_type: route_type,
1149
+ route_color: route_color
1150
+ }
1151
+ end
1152
+ end
1153
+ end
1154
+ end
1155
+ end
1156
+ ```
1157
+
1158
+ **Step 7: Run tests to verify they pass**
1159
+
1160
+ Run: `bundle exec rspec spec/njtransit/gtfs/models/ -v`
1161
+ Expected: PASS
1162
+
1163
+ **Step 8: Commit**
1164
+
1165
+ ```bash
1166
+ git add lib/njtransit/gtfs/models/ spec/njtransit/gtfs/models/
1167
+ git commit -m "feat: add Stop and Route GTFS models"
1168
+ ```
1169
+
1170
+ ---
1171
+
1172
+ ## Task 8: Create Routes Between Query
1173
+
1174
+ **Files:**
1175
+ - Create: `lib/njtransit/gtfs/queries/routes_between.rb`
1176
+ - Create: `spec/njtransit/gtfs/queries/routes_between_spec.rb`
1177
+
1178
+ **Step 1: Write the failing test**
1179
+
1180
+ Create `spec/njtransit/gtfs/queries/routes_between_spec.rb`:
1181
+
1182
+ ```ruby
1183
+ # frozen_string_literal: true
1184
+
1185
+ require "fileutils"
1186
+
1187
+ RSpec.describe NJTransit::GTFS::Queries::RoutesBetween do
1188
+ let(:test_db_path) { "tmp/test_queries.sqlite3" }
1189
+
1190
+ before(:all) do
1191
+ FileUtils.mkdir_p("tmp")
1192
+ FileUtils.rm_f("tmp/test_queries.sqlite3")
1193
+ NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_queries.sqlite3").import
1194
+ end
1195
+
1196
+ after(:all) do
1197
+ NJTransit::GTFS::Database.disconnect
1198
+ FileUtils.rm_f("tmp/test_queries.sqlite3")
1199
+ end
1200
+
1201
+ let(:db) { NJTransit::GTFS::Database.connection(test_db_path) }
1202
+
1203
+ describe "#call" do
1204
+ it "finds routes serving both stops" do
1205
+ query = described_class.new(db, from: "WBRK", to: "PABT")
1206
+ routes = query.call
1207
+ expect(routes).to include("197")
1208
+ end
1209
+
1210
+ it "returns empty array when no routes connect stops" do
1211
+ query = described_class.new(db, from: "21681", to: "21682")
1212
+ routes = query.call
1213
+ expect(routes).to be_empty
1214
+ end
1215
+
1216
+ it "accepts stop_id or stop_code" do
1217
+ query = described_class.new(db, from: "1", to: "2")
1218
+ routes = query.call
1219
+ expect(routes).to include("197")
1220
+ end
1221
+ end
1222
+ end
1223
+ ```
1224
+
1225
+ **Step 2: Run test to verify it fails**
1226
+
1227
+ Run: `bundle exec rspec spec/njtransit/gtfs/queries/routes_between_spec.rb -v`
1228
+ Expected: FAIL with "uninitialized constant"
1229
+
1230
+ **Step 3: Create directories**
1231
+
1232
+ Run: `mkdir -p lib/njtransit/gtfs/queries spec/njtransit/gtfs/queries`
1233
+
1234
+ **Step 4: Write implementation**
1235
+
1236
+ Create `lib/njtransit/gtfs/queries/routes_between.rb`:
1237
+
1238
+ ```ruby
1239
+ # frozen_string_literal: true
1240
+
1241
+ module NJTransit
1242
+ module GTFS
1243
+ module Queries
1244
+ class RoutesBetween
1245
+ attr_reader :db, :from, :to
1246
+
1247
+ def initialize(db, from:, to:)
1248
+ @db = db
1249
+ @from = from
1250
+ @to = to
1251
+ end
1252
+
1253
+ def call
1254
+ from_stop_id = resolve_stop_id(from)
1255
+ to_stop_id = resolve_stop_id(to)
1256
+
1257
+ return [] if from_stop_id.nil? || to_stop_id.nil?
1258
+
1259
+ # Find trips that stop at both locations
1260
+ from_trips = db[:stop_times].where(stop_id: from_stop_id).select_map(:trip_id)
1261
+ to_trips = db[:stop_times].where(stop_id: to_stop_id).select_map(:trip_id)
1262
+
1263
+ common_trips = from_trips & to_trips
1264
+ return [] if common_trips.empty?
1265
+
1266
+ # Get route_ids for those trips
1267
+ route_ids = db[:trips].where(trip_id: common_trips).select_map(:route_id).uniq
1268
+
1269
+ # Get route short names
1270
+ db[:routes].where(route_id: route_ids).select_map(:route_short_name).uniq
1271
+ end
1272
+
1273
+ private
1274
+
1275
+ def resolve_stop_id(identifier)
1276
+ # Try as stop_id first
1277
+ stop = db[:stops].where(stop_id: identifier).first
1278
+ return identifier if stop
1279
+
1280
+ # Try as stop_code
1281
+ stop = db[:stops].where(stop_code: identifier).first
1282
+ stop&.dig(:stop_id)
1283
+ end
1284
+ end
1285
+ end
1286
+ end
1287
+ end
1288
+ ```
1289
+
1290
+ **Step 5: Run test to verify it passes**
1291
+
1292
+ Run: `bundle exec rspec spec/njtransit/gtfs/queries/routes_between_spec.rb -v`
1293
+ Expected: PASS
1294
+
1295
+ **Step 6: Commit**
1296
+
1297
+ ```bash
1298
+ git add lib/njtransit/gtfs/queries/ spec/njtransit/gtfs/queries/
1299
+ git commit -m "feat: add routes_between query"
1300
+ ```
1301
+
1302
+ ---
1303
+
1304
+ ## Task 9: Create Schedule Query
1305
+
1306
+ **Files:**
1307
+ - Create: `lib/njtransit/gtfs/queries/schedule.rb`
1308
+ - Create: `spec/njtransit/gtfs/queries/schedule_spec.rb`
1309
+
1310
+ **Step 1: Write the failing test**
1311
+
1312
+ Create `spec/njtransit/gtfs/queries/schedule_spec.rb`:
1313
+
1314
+ ```ruby
1315
+ # frozen_string_literal: true
1316
+
1317
+ require "fileutils"
1318
+
1319
+ RSpec.describe NJTransit::GTFS::Queries::Schedule do
1320
+ let(:test_db_path) { "tmp/test_queries.sqlite3" }
1321
+
1322
+ before(:all) do
1323
+ FileUtils.mkdir_p("tmp")
1324
+ FileUtils.rm_f("tmp/test_queries.sqlite3")
1325
+ NJTransit::GTFS::Importer.new("spec/fixtures/gtfs", "tmp/test_queries.sqlite3").import
1326
+ end
1327
+
1328
+ after(:all) do
1329
+ NJTransit::GTFS::Database.disconnect
1330
+ FileUtils.rm_f("tmp/test_queries.sqlite3")
1331
+ end
1332
+
1333
+ let(:db) { NJTransit::GTFS::Database.connection(test_db_path) }
1334
+
1335
+ describe "#call" do
1336
+ it "returns schedule for route at stop on date" do
1337
+ # Date 20260124 has service_id 1
1338
+ query = described_class.new(db, route: "197", stop: "WBRK", date: Date.new(2026, 1, 24))
1339
+ schedule = query.call
1340
+ expect(schedule).not_to be_empty
1341
+ expect(schedule.first).to have_key(:arrival_time)
1342
+ expect(schedule.first).to have_key(:departure_time)
1343
+ expect(schedule.first).to have_key(:trip_id)
1344
+ end
1345
+
1346
+ it "returns empty array when no service on date" do
1347
+ query = described_class.new(db, route: "197", stop: "WBRK", date: Date.new(2026, 1, 20))
1348
+ schedule = query.call
1349
+ expect(schedule).to be_empty
1350
+ end
1351
+
1352
+ it "orders by arrival time" do
1353
+ query = described_class.new(db, route: "197", stop: "WBRK", date: Date.new(2026, 1, 24))
1354
+ schedule = query.call
1355
+ times = schedule.map { |s| s[:arrival_time] }
1356
+ expect(times).to eq(times.sort)
1357
+ end
1358
+ end
1359
+ end
1360
+ ```
1361
+
1362
+ **Step 2: Run test to verify it fails**
1363
+
1364
+ Run: `bundle exec rspec spec/njtransit/gtfs/queries/schedule_spec.rb -v`
1365
+ Expected: FAIL with "uninitialized constant"
1366
+
1367
+ **Step 3: Write implementation**
1368
+
1369
+ Create `lib/njtransit/gtfs/queries/schedule.rb`:
1370
+
1371
+ ```ruby
1372
+ # frozen_string_literal: true
1373
+
1374
+ module NJTransit
1375
+ module GTFS
1376
+ module Queries
1377
+ class Schedule
1378
+ attr_reader :db, :route, :stop, :date
1379
+
1380
+ def initialize(db, route:, stop:, date:)
1381
+ @db = db
1382
+ @route = route
1383
+ @stop = stop
1384
+ @date = date
1385
+ end
1386
+
1387
+ def call
1388
+ route_id = resolve_route_id
1389
+ stop_id = resolve_stop_id
1390
+ service_ids = active_service_ids
1391
+
1392
+ return [] if route_id.nil? || stop_id.nil? || service_ids.empty?
1393
+
1394
+ # Find trips for this route on active services
1395
+ trip_ids = db[:trips]
1396
+ .where(route_id: route_id, service_id: service_ids)
1397
+ .select_map(:trip_id)
1398
+
1399
+ return [] if trip_ids.empty?
1400
+
1401
+ # Get stop times for these trips at this stop
1402
+ db[:stop_times]
1403
+ .where(trip_id: trip_ids, stop_id: stop_id)
1404
+ .order(:arrival_time)
1405
+ .all
1406
+ .map do |row|
1407
+ {
1408
+ trip_id: row[:trip_id],
1409
+ arrival_time: row[:arrival_time],
1410
+ departure_time: row[:departure_time],
1411
+ stop_sequence: row[:stop_sequence]
1412
+ }
1413
+ end
1414
+ end
1415
+
1416
+ private
1417
+
1418
+ def resolve_route_id
1419
+ route_row = db[:routes].where(route_id: route).first
1420
+ route_row ||= db[:routes].where(route_short_name: route).first
1421
+ route_row&.dig(:route_id)
1422
+ end
1423
+
1424
+ def resolve_stop_id
1425
+ stop_row = db[:stops].where(stop_id: stop).first
1426
+ stop_row ||= db[:stops].where(stop_code: stop).first
1427
+ stop_row&.dig(:stop_id)
1428
+ end
1429
+
1430
+ def active_service_ids
1431
+ date_str = date.strftime("%Y%m%d")
1432
+ db[:calendar_dates]
1433
+ .where(date: date_str, exception_type: 1)
1434
+ .select_map(:service_id)
1435
+ end
1436
+ end
1437
+ end
1438
+ end
1439
+ end
1440
+ ```
1441
+
1442
+ **Step 4: Run test to verify it passes**
1443
+
1444
+ Run: `bundle exec rspec spec/njtransit/gtfs/queries/schedule_spec.rb -v`
1445
+ Expected: PASS
1446
+
1447
+ **Step 5: Commit**
1448
+
1449
+ ```bash
1450
+ git add lib/njtransit/gtfs/queries/schedule.rb spec/njtransit/gtfs/queries/schedule_spec.rb
1451
+ git commit -m "feat: add schedule query"
1452
+ ```
1453
+
1454
+ ---
1455
+
1456
+ ## Task 10: Create Main GTFS Module
1457
+
1458
+ **Files:**
1459
+ - Create: `lib/njtransit/gtfs.rb`
1460
+ - Update: `lib/njtransit.rb`
1461
+ - Create: `spec/njtransit/gtfs_spec.rb`
1462
+
1463
+ **Step 1: Write the failing test**
1464
+
1465
+ Create `spec/njtransit/gtfs_spec.rb`:
1466
+
1467
+ ```ruby
1468
+ # frozen_string_literal: true
1469
+
1470
+ require "fileutils"
1471
+
1472
+ RSpec.describe NJTransit::GTFS do
1473
+ let(:fixtures_path) { "spec/fixtures/gtfs" }
1474
+ let(:test_db_path) { "tmp/test_gtfs_main.sqlite3" }
1475
+
1476
+ before do
1477
+ FileUtils.mkdir_p("tmp")
1478
+ FileUtils.rm_f(test_db_path)
1479
+ allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return(test_db_path)
1480
+ end
1481
+
1482
+ after do
1483
+ NJTransit::GTFS::Database.disconnect
1484
+ FileUtils.rm_f(test_db_path)
1485
+ end
1486
+
1487
+ describe ".import" do
1488
+ it "imports GTFS data" do
1489
+ described_class.import(fixtures_path)
1490
+ expect(NJTransit::GTFS::Database.exists?(test_db_path)).to be true
1491
+ end
1492
+
1493
+ it "raises error for invalid directory" do
1494
+ expect {
1495
+ described_class.import("/nonexistent")
1496
+ }.to raise_error(NJTransit::Error, /Invalid GTFS directory/)
1497
+ end
1498
+ end
1499
+
1500
+ describe ".status" do
1501
+ context "when not imported" do
1502
+ it "returns imported: false" do
1503
+ status = described_class.status
1504
+ expect(status[:imported]).to be false
1505
+ end
1506
+ end
1507
+
1508
+ context "when imported" do
1509
+ before { described_class.import(fixtures_path) }
1510
+
1511
+ it "returns imported: true" do
1512
+ status = described_class.status
1513
+ expect(status[:imported]).to be true
1514
+ end
1515
+
1516
+ it "returns record counts" do
1517
+ status = described_class.status
1518
+ expect(status[:routes]).to eq(3)
1519
+ expect(status[:stops]).to eq(5)
1520
+ end
1521
+ end
1522
+ end
1523
+
1524
+ describe ".new" do
1525
+ context "when GTFS imported" do
1526
+ before { described_class.import(fixtures_path) }
1527
+
1528
+ it "returns a query interface" do
1529
+ gtfs = described_class.new
1530
+ expect(gtfs).to be_a(NJTransit::GTFS::QueryInterface)
1531
+ end
1532
+ end
1533
+
1534
+ context "when GTFS not imported" do
1535
+ it "raises GTFSNotImportedError" do
1536
+ expect {
1537
+ described_class.new
1538
+ }.to raise_error(NJTransit::GTFSNotImportedError)
1539
+ end
1540
+ end
1541
+ end
1542
+
1543
+ describe ".detect_gtfs_path" do
1544
+ it "returns nil when no GTFS files found" do
1545
+ expect(described_class.detect_gtfs_path).to be_nil
1546
+ end
1547
+
1548
+ it "returns path when GTFS files exist" do
1549
+ allow(File).to receive(:exist?).and_call_original
1550
+ allow(File).to receive(:exist?).with("./docs/api/njtransit/bus_data/agency.txt").and_return(true)
1551
+ allow(File).to receive(:directory?).with("./docs/api/njtransit/bus_data").and_return(true)
1552
+
1553
+ expect(described_class.detect_gtfs_path).to eq("./docs/api/njtransit/bus_data")
1554
+ end
1555
+ end
1556
+ end
1557
+
1558
+ RSpec.describe NJTransit::GTFS::QueryInterface do
1559
+ let(:fixtures_path) { "spec/fixtures/gtfs" }
1560
+ let(:test_db_path) { "tmp/test_gtfs_query.sqlite3" }
1561
+
1562
+ before(:all) do
1563
+ FileUtils.mkdir_p("tmp")
1564
+ FileUtils.rm_f("tmp/test_gtfs_query.sqlite3")
1565
+ allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return("tmp/test_gtfs_query.sqlite3")
1566
+ NJTransit::GTFS.import("spec/fixtures/gtfs")
1567
+ end
1568
+
1569
+ after(:all) do
1570
+ NJTransit::GTFS::Database.disconnect
1571
+ FileUtils.rm_f("tmp/test_gtfs_query.sqlite3")
1572
+ end
1573
+
1574
+ let(:gtfs) { NJTransit::GTFS.new }
1575
+
1576
+ before do
1577
+ allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return(test_db_path)
1578
+ end
1579
+
1580
+ describe "#stops" do
1581
+ it "provides stop query methods" do
1582
+ expect(gtfs.stops.all.count).to eq(5)
1583
+ end
1584
+
1585
+ it "finds by code" do
1586
+ stop = gtfs.stops.find_by_code("WBRK")
1587
+ expect(stop.stop_name).to eq("WILLOWBROOK MALL")
1588
+ end
1589
+ end
1590
+
1591
+ describe "#routes" do
1592
+ it "provides route query methods" do
1593
+ expect(gtfs.routes.all.count).to eq(3)
1594
+ end
1595
+ end
1596
+
1597
+ describe "#routes_between" do
1598
+ it "finds routes between two stops" do
1599
+ routes = gtfs.routes_between(from: "WBRK", to: "PABT")
1600
+ expect(routes).to include("197")
1601
+ end
1602
+ end
1603
+
1604
+ describe "#schedule" do
1605
+ it "returns schedule for route/stop/date" do
1606
+ schedule = gtfs.schedule(route: "197", stop: "WBRK", date: Date.new(2026, 1, 24))
1607
+ expect(schedule).not_to be_empty
1608
+ end
1609
+ end
1610
+ end
1611
+ ```
1612
+
1613
+ **Step 2: Run test to verify it fails**
1614
+
1615
+ Run: `bundle exec rspec spec/njtransit/gtfs_spec.rb -v`
1616
+ Expected: FAIL
1617
+
1618
+ **Step 3: Write implementation**
1619
+
1620
+ Create `lib/njtransit/gtfs.rb`:
1621
+
1622
+ ```ruby
1623
+ # frozen_string_literal: true
1624
+
1625
+ require_relative "gtfs/database"
1626
+ require_relative "gtfs/importer"
1627
+ require_relative "gtfs/models/stop"
1628
+ require_relative "gtfs/models/route"
1629
+ require_relative "gtfs/queries/routes_between"
1630
+ require_relative "gtfs/queries/schedule"
1631
+
1632
+ module NJTransit
1633
+ module GTFS
1634
+ SEARCH_PATHS = [
1635
+ "./bus_data",
1636
+ "./vendor/bus_data",
1637
+ "./docs/api/njtransit/bus_data"
1638
+ ].freeze
1639
+
1640
+ class << self
1641
+ def import(source_path, force: false)
1642
+ importer = Importer.new(source_path, database_path)
1643
+
1644
+ unless importer.valid_gtfs_directory?
1645
+ raise NJTransit::Error, "Invalid GTFS directory: #{source_path}. Must contain agency.txt, routes.txt, stops.txt"
1646
+ end
1647
+
1648
+ importer.import(force: force)
1649
+ end
1650
+
1651
+ def status
1652
+ path = database_path
1653
+ return { imported: false, path: path } unless Database.exists?(path)
1654
+
1655
+ Database.connection(path)
1656
+ db = Database.connection
1657
+
1658
+ metadata = db[:import_metadata].order(Sequel.desc(:id)).first
1659
+
1660
+ {
1661
+ imported: true,
1662
+ path: path,
1663
+ routes: db[:routes].count,
1664
+ stops: db[:stops].count,
1665
+ trips: db[:trips].count,
1666
+ stop_times: db[:stop_times].count,
1667
+ imported_at: metadata&.dig(:imported_at),
1668
+ source_path: metadata&.dig(:source_path)
1669
+ }
1670
+ end
1671
+
1672
+ def new
1673
+ path = database_path
1674
+
1675
+ unless Database.exists?(path)
1676
+ detected = detect_gtfs_path
1677
+ raise GTFSNotImportedError.new(detected_path: detected)
1678
+ end
1679
+
1680
+ QueryInterface.new(path)
1681
+ end
1682
+
1683
+ def detect_gtfs_path
1684
+ SEARCH_PATHS.find do |path|
1685
+ File.directory?(path) && File.exist?(File.join(path, "agency.txt"))
1686
+ end
1687
+ end
1688
+
1689
+ def clear!
1690
+ Database.connection(database_path)
1691
+ Database.clear!
1692
+ Database.disconnect
1693
+ FileUtils.rm_f(database_path)
1694
+ end
1695
+
1696
+ private
1697
+
1698
+ def database_path
1699
+ NJTransit.configuration.gtfs_database_path
1700
+ end
1701
+ end
1702
+
1703
+ class QueryInterface
1704
+ attr_reader :db
1705
+
1706
+ def initialize(db_path)
1707
+ Database.connection(db_path)
1708
+ @db = Database.connection
1709
+ setup_models
1710
+ end
1711
+
1712
+ def stops
1713
+ Models::Stop
1714
+ end
1715
+
1716
+ def routes
1717
+ Models::Route
1718
+ end
1719
+
1720
+ def routes_between(from:, to:)
1721
+ Queries::RoutesBetween.new(db, from: from, to: to).call
1722
+ end
1723
+
1724
+ def schedule(route:, stop:, date:)
1725
+ Queries::Schedule.new(db, route: route, stop: stop, date: date).call
1726
+ end
1727
+
1728
+ private
1729
+
1730
+ def setup_models
1731
+ Models::Stop.db = db
1732
+ Models::Route.db = db
1733
+ end
1734
+ end
1735
+ end
1736
+ end
1737
+ ```
1738
+
1739
+ **Step 4: Update lib/njtransit.rb**
1740
+
1741
+ Add after line 5 (`require_relative "njtransit/client"`):
1742
+
1743
+ ```ruby
1744
+ require_relative "njtransit/gtfs"
1745
+ ```
1746
+
1747
+ **Step 5: Run test to verify it passes**
1748
+
1749
+ Run: `bundle exec rspec spec/njtransit/gtfs_spec.rb -v`
1750
+ Expected: PASS
1751
+
1752
+ **Step 6: Commit**
1753
+
1754
+ ```bash
1755
+ git add lib/njtransit/gtfs.rb lib/njtransit.rb spec/njtransit/gtfs_spec.rb
1756
+ git commit -m "feat: add main GTFS module with query interface"
1757
+ ```
1758
+
1759
+ ---
1760
+
1761
+ ## Task 11: Add Rake Tasks
1762
+
1763
+ **Files:**
1764
+ - Create: `lib/njtransit/tasks.rb`
1765
+ - Create: `lib/njtransit/railtie.rb`
1766
+
1767
+ **Step 1: Create Rake tasks**
1768
+
1769
+ Create `lib/njtransit/tasks.rb`:
1770
+
1771
+ ```ruby
1772
+ # frozen_string_literal: true
1773
+
1774
+ require "rake"
1775
+
1776
+ namespace :njtransit do
1777
+ namespace :gtfs do
1778
+ desc "Import GTFS data from specified path"
1779
+ task :import, [:path] do |_t, args|
1780
+ require "njtransit"
1781
+
1782
+ path = args[:path]
1783
+ if path.nil? || path.empty?
1784
+ detected = NJTransit::GTFS.detect_gtfs_path
1785
+ if detected
1786
+ puts "No path specified. Detected GTFS data at: #{detected}"
1787
+ print "Use this path? [Y/n] "
1788
+ response = $stdin.gets&.strip&.downcase
1789
+ path = detected if response.nil? || response.empty? || response == "y"
1790
+ end
1791
+ end
1792
+
1793
+ if path.nil? || path.empty?
1794
+ puts "Usage: rake njtransit:gtfs:import[/path/to/gtfs/data]"
1795
+ exit 1
1796
+ end
1797
+
1798
+ puts "Importing GTFS data from #{path}..."
1799
+ NJTransit::GTFS.import(path, force: ENV["FORCE"] == "true")
1800
+ status = NJTransit::GTFS.status
1801
+ puts "Import complete!"
1802
+ puts " Routes: #{status[:routes]}"
1803
+ puts " Stops: #{status[:stops]}"
1804
+ puts " Trips: #{status[:trips]}"
1805
+ puts " Stop times: #{status[:stop_times]}"
1806
+ puts " Database: #{status[:path]}"
1807
+ end
1808
+
1809
+ desc "Show GTFS import status"
1810
+ task :status do
1811
+ require "njtransit"
1812
+
1813
+ status = NJTransit::GTFS.status
1814
+ if status[:imported]
1815
+ puts "GTFS Status: Imported"
1816
+ puts " Database: #{status[:path]}"
1817
+ puts " Routes: #{status[:routes]}"
1818
+ puts " Stops: #{status[:stops]}"
1819
+ puts " Trips: #{status[:trips]}"
1820
+ puts " Stop times: #{status[:stop_times]}"
1821
+ puts " Imported at: #{status[:imported_at]}"
1822
+ puts " Source: #{status[:source_path]}"
1823
+ else
1824
+ puts "GTFS Status: Not imported"
1825
+ puts " Database path: #{status[:path]}"
1826
+ detected = NJTransit::GTFS.detect_gtfs_path
1827
+ puts " Detected GTFS data: #{detected}" if detected
1828
+ end
1829
+ end
1830
+
1831
+ desc "Clear GTFS database"
1832
+ task :clear do
1833
+ require "njtransit"
1834
+
1835
+ print "Are you sure you want to clear the GTFS database? [y/N] "
1836
+ response = $stdin.gets&.strip&.downcase
1837
+ if response == "y"
1838
+ NJTransit::GTFS.clear!
1839
+ puts "GTFS database cleared."
1840
+ else
1841
+ puts "Cancelled."
1842
+ end
1843
+ end
1844
+ end
1845
+ end
1846
+ ```
1847
+
1848
+ **Step 2: Create Railtie for Rails auto-loading**
1849
+
1850
+ Create `lib/njtransit/railtie.rb`:
1851
+
1852
+ ```ruby
1853
+ # frozen_string_literal: true
1854
+
1855
+ module NJTransit
1856
+ class Railtie < Rails::Railtie
1857
+ rake_tasks do
1858
+ load "njtransit/tasks.rb"
1859
+ end
1860
+ end
1861
+ end
1862
+ ```
1863
+
1864
+ **Step 3: Update lib/njtransit.rb to load railtie**
1865
+
1866
+ Add at the end of the file (before final `end`):
1867
+
1868
+ ```ruby
1869
+ require_relative "njtransit/railtie" if defined?(Rails::Railtie)
1870
+ ```
1871
+
1872
+ **Step 4: Commit**
1873
+
1874
+ ```bash
1875
+ git add lib/njtransit/tasks.rb lib/njtransit/railtie.rb lib/njtransit.rb
1876
+ git commit -m "feat: add Rake tasks for GTFS import/status/clear"
1877
+ ```
1878
+
1879
+ ---
1880
+
1881
+ ## Task 12: Add Bus API Enrichment
1882
+
1883
+ **Files:**
1884
+ - Modify: `lib/njtransit/resources/bus.rb`
1885
+ - Create: `spec/njtransit/resources/bus_enrichment_spec.rb`
1886
+
1887
+ **Step 1: Write the failing test**
1888
+
1889
+ Create `spec/njtransit/resources/bus_enrichment_spec.rb`:
1890
+
1891
+ ```ruby
1892
+ # frozen_string_literal: true
1893
+
1894
+ require "fileutils"
1895
+
1896
+ RSpec.describe "Bus API Enrichment" do
1897
+ let(:test_db_path) { "tmp/test_enrichment.sqlite3" }
1898
+ let(:client) { instance_double(NJTransit::Client) }
1899
+ let(:bus) { NJTransit::Resources::Bus.new(client) }
1900
+
1901
+ before do
1902
+ FileUtils.mkdir_p("tmp")
1903
+ FileUtils.rm_f(test_db_path)
1904
+ allow(NJTransit.configuration).to receive(:gtfs_database_path).and_return(test_db_path)
1905
+ NJTransit::GTFS.import("spec/fixtures/gtfs")
1906
+ allow(client).to receive(:token).and_return("test_token")
1907
+ end
1908
+
1909
+ after do
1910
+ NJTransit::GTFS::Database.disconnect
1911
+ FileUtils.rm_f(test_db_path)
1912
+ end
1913
+
1914
+ describe "#stops with enrichment" do
1915
+ let(:api_response) do
1916
+ [
1917
+ { "stop_id" => "WBRK", "stop_name" => "WILLOWBROOK" },
1918
+ { "stop_id" => "PABT", "stop_name" => "PORT AUTHORITY" }
1919
+ ]
1920
+ end
1921
+
1922
+ before do
1923
+ allow(client).to receive(:post_form).and_return(api_response)
1924
+ end
1925
+
1926
+ it "adds lat/lon from GTFS by default" do
1927
+ result = bus.stops(route: "197", direction: "New York")
1928
+ expect(result.first["stop_lat"]).to eq(40.8523)
1929
+ expect(result.first["stop_lon"]).to eq(-74.2567)
1930
+ end
1931
+
1932
+ it "skips enrichment when enrich: false" do
1933
+ result = bus.stops(route: "197", direction: "New York", enrich: false)
1934
+ expect(result.first).not_to have_key("stop_lat")
1935
+ end
1936
+ end
1937
+
1938
+ describe "#departures with enrichment" do
1939
+ let(:api_response) do
1940
+ [
1941
+ { "stop_id" => "WBRK", "route" => "197" }
1942
+ ]
1943
+ end
1944
+
1945
+ before do
1946
+ allow(client).to receive(:post_form).and_return(api_response)
1947
+ end
1948
+
1949
+ it "adds stop coordinates and route name" do
1950
+ result = bus.departures(stop: "WBRK")
1951
+ expect(result.first["stop_lat"]).to eq(40.8523)
1952
+ expect(result.first["route_long_name"]).to eq("Willowbrook - Port Authority")
1953
+ end
1954
+ end
1955
+
1956
+ describe "when GTFS not imported" do
1957
+ before do
1958
+ NJTransit::GTFS.clear!
1959
+ NJTransit::GTFS::Database.disconnect
1960
+ FileUtils.rm_f(test_db_path)
1961
+ end
1962
+
1963
+ it "raises GTFSNotImportedError for enriched calls" do
1964
+ expect {
1965
+ bus.stops(route: "197", direction: "New York")
1966
+ }.to raise_error(NJTransit::GTFSNotImportedError)
1967
+ end
1968
+
1969
+ it "succeeds with enrich: false" do
1970
+ allow(client).to receive(:post_form).and_return([])
1971
+ expect {
1972
+ bus.stops(route: "197", direction: "New York", enrich: false)
1973
+ }.not_to raise_error
1974
+ end
1975
+ end
1976
+ end
1977
+ ```
1978
+
1979
+ **Step 2: Run test to verify it fails**
1980
+
1981
+ Run: `bundle exec rspec spec/njtransit/resources/bus_enrichment_spec.rb -v`
1982
+ Expected: FAIL (enrichment not implemented)
1983
+
1984
+ **Step 3: Write implementation**
1985
+
1986
+ Replace `lib/njtransit/resources/bus.rb`:
1987
+
1988
+ ```ruby
1989
+ # frozen_string_literal: true
1990
+
1991
+ module NJTransit
1992
+ module Resources
1993
+ class Bus < Base
1994
+ MODE = "BUS"
1995
+
1996
+ def locations
1997
+ post_form("/api/BUSDV2/getLocations", mode: MODE)
1998
+ end
1999
+
2000
+ def routes
2001
+ post_form("/api/BUSDV2/getBusRoutes", mode: MODE)
2002
+ end
2003
+
2004
+ def directions(route:)
2005
+ post_form("/api/BUSDV2/getBusDirectionsData", route: route)
2006
+ end
2007
+
2008
+ def stops(route:, direction:, name_contains: nil, enrich: true)
2009
+ params = { route: route, direction: direction }
2010
+ params[:namecontains] = name_contains if name_contains
2011
+ result = post_form("/api/BUSDV2/getStops", params)
2012
+ enrich ? enrich_stops(result) : result
2013
+ end
2014
+
2015
+ def stop_name(stop_number:, enrich: true)
2016
+ result = post_form("/api/BUSDV2/getStopName", stopnum: stop_number)
2017
+ enrich ? enrich_stop_name(result, stop_number) : result
2018
+ end
2019
+
2020
+ def route_trips(location:, route:)
2021
+ post_form("/api/BUSDV2/getRouteTrips", location: location, route: route)
2022
+ end
2023
+
2024
+ def departures(stop:, route: nil, direction: nil, enrich: true)
2025
+ params = { stop: stop }
2026
+ params[:route] = route if route
2027
+ params[:direction] = direction if direction
2028
+ result = post_form("/api/BUSDV2/getBusDV", params)
2029
+ enrich ? enrich_departures(result) : result
2030
+ end
2031
+
2032
+ def trip_stops(internal_trip_number:, sched_dep_time:, timing_point_id: nil)
2033
+ params = {
2034
+ internal_trip_number: internal_trip_number,
2035
+ sched_dep_time: sched_dep_time
2036
+ }
2037
+ params[:timing_point_id] = timing_point_id if timing_point_id
2038
+ post_form("/api/BUSDV2/getTripStops", params)
2039
+ end
2040
+
2041
+ def stops_nearby(lat:, lon:, radius:, route: nil, direction: nil, enrich: true)
2042
+ params = { lat: lat, lon: lon, radius: radius, mode: MODE }
2043
+ params[:route] = route if route
2044
+ params[:direction] = direction if direction
2045
+ result = post_form("/api/BUSDV2/getBusLocationsData", params)
2046
+ enrich ? enrich_stops_nearby(result) : result
2047
+ end
2048
+
2049
+ def vehicles_nearby(lat:, lon:, radius:, enrich: true)
2050
+ result = post_form("/api/BUSDV2/getVehicleLocations", lat: lat, lon: lon, radius: radius, mode: MODE)
2051
+ enrich ? enrich_vehicles(result) : result
2052
+ end
2053
+
2054
+ private
2055
+
2056
+ def post_form(path, params = {})
2057
+ params[:token] = client.token
2058
+ client.post_form(path, params)
2059
+ end
2060
+
2061
+ def gtfs
2062
+ @gtfs ||= GTFS.new
2063
+ end
2064
+
2065
+ def enrich_stops(stops)
2066
+ return stops unless stops.is_a?(Array)
2067
+
2068
+ stops.each do |stop|
2069
+ stop_code = stop["stop_id"] || stop[:stop_id]
2070
+ next unless stop_code
2071
+
2072
+ gtfs_stop = gtfs.stops.find_by_code(stop_code.to_s)
2073
+ next unless gtfs_stop
2074
+
2075
+ stop["stop_lat"] = gtfs_stop.lat
2076
+ stop["stop_lon"] = gtfs_stop.lon
2077
+ stop["zone_id"] = gtfs_stop.zone_id
2078
+ end
2079
+ stops
2080
+ end
2081
+
2082
+ def enrich_stop_name(result, stop_number)
2083
+ gtfs_stop = gtfs.stops.find_by_code(stop_number.to_s)
2084
+ return result unless gtfs_stop
2085
+
2086
+ if result.is_a?(Hash)
2087
+ result["stop_lat"] = gtfs_stop.lat
2088
+ result["stop_lon"] = gtfs_stop.lon
2089
+ end
2090
+ result
2091
+ end
2092
+
2093
+ def enrich_departures(departures)
2094
+ return departures unless departures.is_a?(Array)
2095
+
2096
+ departures.each do |dep|
2097
+ # Enrich stop info
2098
+ stop_code = dep["stop_id"] || dep[:stop_id]
2099
+ if stop_code
2100
+ gtfs_stop = gtfs.stops.find_by_code(stop_code.to_s)
2101
+ if gtfs_stop
2102
+ dep["stop_lat"] = gtfs_stop.lat
2103
+ dep["stop_lon"] = gtfs_stop.lon
2104
+ end
2105
+ end
2106
+
2107
+ # Enrich route info
2108
+ route_name = dep["route"] || dep[:route]
2109
+ if route_name
2110
+ gtfs_route = gtfs.routes.find(route_name.to_s)
2111
+ dep["route_long_name"] = gtfs_route.long_name if gtfs_route
2112
+ end
2113
+ end
2114
+ departures
2115
+ end
2116
+
2117
+ def enrich_stops_nearby(stops)
2118
+ return stops unless stops.is_a?(Array)
2119
+
2120
+ stops.each do |stop|
2121
+ stop_code = stop["stop_id"] || stop[:stop_id]
2122
+ next unless stop_code
2123
+
2124
+ gtfs_stop = gtfs.stops.find_by_code(stop_code.to_s)
2125
+ stop["zone_id"] = gtfs_stop.zone_id if gtfs_stop
2126
+ end
2127
+ stops
2128
+ end
2129
+
2130
+ def enrich_vehicles(vehicles)
2131
+ return vehicles unless vehicles.is_a?(Array)
2132
+
2133
+ vehicles.each do |vehicle|
2134
+ route_name = vehicle["route"] || vehicle[:route]
2135
+ next unless route_name
2136
+
2137
+ gtfs_route = gtfs.routes.find(route_name.to_s)
2138
+ vehicle["route_long_name"] = gtfs_route.long_name if gtfs_route
2139
+ end
2140
+ vehicles
2141
+ end
2142
+ end
2143
+ end
2144
+ end
2145
+ ```
2146
+
2147
+ **Step 4: Run test to verify it passes**
2148
+
2149
+ Run: `bundle exec rspec spec/njtransit/resources/bus_enrichment_spec.rb -v`
2150
+ Expected: PASS
2151
+
2152
+ **Step 5: Commit**
2153
+
2154
+ ```bash
2155
+ git add lib/njtransit/resources/bus.rb spec/njtransit/resources/bus_enrichment_spec.rb
2156
+ git commit -m "feat: add automatic GTFS enrichment to Bus API responses"
2157
+ ```
2158
+
2159
+ ---
2160
+
2161
+ ## Task 13: Run Full Test Suite
2162
+
2163
+ **Step 1: Run all tests**
2164
+
2165
+ Run: `bundle exec rspec --format documentation`
2166
+ Expected: All tests pass
2167
+
2168
+ **Step 2: Run RuboCop**
2169
+
2170
+ Run: `bundle exec rubocop`
2171
+ Expected: No offenses (or fix any that appear)
2172
+
2173
+ **Step 3: Commit any fixes**
2174
+
2175
+ ```bash
2176
+ git add -A
2177
+ git commit -m "chore: fix rubocop offenses"
2178
+ ```
2179
+
2180
+ ---
2181
+
2182
+ ## Task 14: Update README
2183
+
2184
+ **Files:**
2185
+ - Modify: `README.md`
2186
+
2187
+ Add GTFS documentation section covering:
2188
+ - Installation (bundle install)
2189
+ - GTFS import (rake task)
2190
+ - Query API usage
2191
+ - Bus API enrichment
2192
+ - Configuration options
2193
+
2194
+ **Commit:**
2195
+
2196
+ ```bash
2197
+ git add README.md
2198
+ git commit -m "docs: add GTFS loader documentation to README"
2199
+ ```
2200
+
2201
+ ---
2202
+
2203
+ ## Summary
2204
+
2205
+ This plan implements the GTFS static data loader with:
2206
+
2207
+ 1. **Dependencies:** Sequel + SQLite3
2208
+ 2. **Database:** XDG-compliant SQLite storage with full schema
2209
+ 3. **Importer:** Batch CSV parsing with progress
2210
+ 4. **Models:** Stop, Route with query methods
2211
+ 5. **Queries:** routes_between, schedule
2212
+ 6. **Rake tasks:** import, status, clear
2213
+ 7. **Enrichment:** Automatic GTFS data in Bus API responses
2214
+ 8. **Testing:** Full coverage with fixtures
2215
+
2216
+ Total: ~14 tasks, each with TDD approach (test → implement → verify → commit)