Repository navigation
Expand file tree
/
Copy pathfetch-skate-data.js
More file actions
3162 lines (2984 loc) · 169 KB
/
Copy pathfetch-skate-data.js
File metadata and controls
3162 lines (2984 loc) · 169 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
/**
* Data Fetcher Script - Downloads City of Toronto Recreation Data
* plus external rink sources (Canlan York, Moss Park Arena), the city
* rink inventory (indoor + outdoor pads → rinks.json) and live skate
* service alerts (→ alerts.json).
*
* Usage:
* node fetch-skate-data.js # full refresh (programs + rinks + alerts + live check)
* node fetch-skate-data.js --alerts-only # light pass: alerts.json + live-check.json (cheap, run often)
*
* toronto.ca live endpoints send no Access-Control-Allow-Origin header,
* so the browser can never fetch them directly — CI snapshots them here.
*
* LIVE CHECK (v3.1, the Malvern lesson): the City's open-data drop-in
* export is refreshed WEEKLY and lags the live registration system — on
* 2026-09-14 it still listed "Leisure Skate: Adult" at Malvern five times
* that week while toronto.ca's own facility page listed none (a visitor
* travelled there for nothing). toronto.ca renders its facility pages
* from per-location week feeds (/data/parks/live/locations/<id>/skate/
* weekN.json). fetchLiveCheck() cross-checks every city session in the
* next two weeks against those feeds and writes live-check.json: sessions
* the City no longer lists are flagged "missing", cancelled ones carry
* the City's comment, and live-only sessions the export lacks are listed
* as "extra". The client treats missing/cancelled like a closure alert.
*
* Design rules:
* - City drop-in data failing is FATAL (keeps the previous files intact).
* - Any external source failing is a WARNING: its previous records are
* salvaged from the existing skating-programs.json so one flaky API
* never wipes a source off the site.
* - Every external source lives in EXTERNAL_SOURCES — adding a rink is
* a config entry + (if it's a new kind) a fetcher, nothing else.
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const PACKAGE_ID = '1a5be46a-4039-48cd-a2d2-8e702abf9516';
const BASE_URL = 'https://ckan0.cf.opendata.inter.prod-toronto.ca/api/3/action';
// Script lives at the repo root, but the app reads from skate/projects/data/
const OUTPUT_DIR = path.join(__dirname, 'skate', 'projects', 'data');
const ALERTS_ONLY = process.argv.includes('--alerts-only');
// Dev: `--only=stouffville,ajax` runs just those external sources and writes nothing.
const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').slice(7).split(',').map(s => s.trim()).filter(Boolean);
/* ================= External source registry =================
* kind 'daysmart' — DaySmart Recreation JSON:API (Canlan etc.)
* kind 'scrape' — HTML page with a human schedule (regex parse,
* optional LLM assist via ANTHROPIC_API_KEY)
* Common fields land on every generated program record so the client
* renders them exactly like city records (+ Paid/Unverified extras).
*/
const EXTERNAL_SOURCES = {
'canlan-york': {
kind: 'daysmart',
company: 'canlan',
// Rinks 1-6 at NFP Athletic Centre (York) — facility_id 5.
// (filter[facility_ids] is silently ignored by their API, so we
// filter by the ice-rink resource ids instead.)
resourceIds: [3, 4, 5, 6, 7, 8],
daysAhead: 28,
// Which published events to keep, and how to label them.
programs: [
{ match: /public\s*skat/i, activity: 'Public Skate', defaultPrice: 5 }
],
locationName: 'NFP Athletic Centre (Canlan York)',
address: '989 Murray Ross Pkwy',
district: 'North York',
postalCode: 'M3J 3M4',
lat: 43.7747279, lng: -79.5137961,
paid: true,
registrationUrl: (date) => `https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?date=${date}&facility_ids=5&program_types=51`,
infoUrl: 'https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?facility_ids=5&program_types=51'
},
'canlan-etobicoke': {
kind: 'daysmart',
company: 'canlan',
// Rinks 1-4 at CWENCH Centre (Etobicoke) — facility_id 3.
resourceIds: [207, 208, 209, 210],
daysAhead: 28,
programs: [
{ match: /public\s*skat/i, activity: 'Public Skate', defaultPrice: 5 }
],
locationName: 'CWENCH Centre (Canlan Etobicoke)',
address: '1120 Martin Grove Rd',
district: 'Etobicoke',
postalCode: 'M9W 4W1',
lat: 43.7002961, lng: -79.5750312,
paid: true,
registrationUrl: (date) => `https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?date=${date}&facility_ids=3&program_types=51`,
infoUrl: 'https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?facility_ids=3&program_types=51'
},
'canlan-scarborough': {
kind: 'daysmart',
company: 'canlan',
// Rinks 1-4 at Canlan Sports Scarborough — facility_id 15. Most
// public skates here have an EMPTY event `desc`; the name only
// lives in the summary / home-team name (fetchDaySmart falls back).
resourceIds: [440, 441, 442, 443],
daysAhead: 28,
programs: [
{ match: /public\s*skat/i, activity: 'Public Skate', defaultPrice: 5 }
],
locationName: 'Canlan Sports Scarborough',
address: '159 Dynamic Dr',
district: 'Scarborough',
postalCode: 'M1V 5L8',
lat: 43.8284798, lng: -79.2524897, // Nominatim; DaySmart's own coordinate is 12.8 km off
paid: true,
registrationUrl: (date) => `https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?date=${date}&facility_ids=15&program_types=51`,
infoUrl: 'https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?facility_ids=15&program_types=51'
},
'canlan-oakville': {
kind: 'daysmart',
company: 'canlan',
// Rinks 1-4 at Entripy Centre (Oakville) — facility_id 13.
// Busiest Canlan calendar (~290 events / 28 days): fetchDaySmart
// filters to the Drop-In event type and pages at 500.
resourceIds: [102, 103, 104, 105],
daysAhead: 28,
programs: [
{ match: /public\s*skat/i, activity: 'Public Skate', defaultPrice: 5 },
// 55+ drop-in; DaySmart lists the product at $0.00
{ match: /senior\s*skat/i, activity: 'Senior Skate (55+)', defaultPrice: 0 }
],
locationName: 'Entripy Centre (Canlan Oakville)',
address: '2300 Cornwall Rd',
district: 'Oakville',
postalCode: 'L6J 7T9',
lat: 43.4882179, lng: -79.6501451,
paid: true,
registrationUrl: (date) => `https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?date=${date}&facility_ids=13&program_types=51`,
infoUrl: 'https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?facility_ids=13&program_types=51'
},
'canlan-oshawa': {
kind: 'daysmart',
company: 'canlan',
// Rinks 1-2 at Canlan Sports Oshawa — facility_id 14. No public
// skate published as of Sep 2026 (Stick & Puck / shinny only) —
// yields 0 records until they add one; harmless.
resourceIds: [54, 65],
daysAhead: 28,
programs: [
{ match: /public\s*skat/i, activity: 'Public Skate', defaultPrice: 5 }
],
locationName: 'Canlan Sports Oshawa',
address: '1401 Phillip Murray Ave',
district: 'Oshawa',
postalCode: 'L1J 8C4',
lat: 43.8546816, lng: -78.8809418,
paid: true,
registrationUrl: (date) => `https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?date=${date}&facility_ids=14&program_types=51`,
infoUrl: 'https://apps.daysmartrecreation.com/dash/x/#/online/canlan/event-registration?facility_ids=14&program_types=51'
},
'markham': {
kind: 'perfectmind',
// City of Markham drop-in skating — official PerfectMind booking
// API (the same JSON their booking page loads). One calendar
// covers all skating drop-ins city-wide; each record names its
// venue in `Location`, so one source can serve many buildings.
base: 'https://cityofmarkham.perfectmind.com',
widgetId: '6825ea71-e5b7-4c2a-948f-9195507ad90a',
calendarId: 'ecf5202d-4c97-4f89-b4e3-42966a1cc453',
daysAhead: 28,
paid: true,
// PriceRange's max is the FAMILY ticket for skates ($13.45); the adult
// drop-in is $5.02 + HST. Shinny/stick-and-puck ranges top out at the
// adult tier already, so only skates get the override.
priceRules: [{ match: /skate/i, price: 5.17 }],
// Names must equal PerfectMind's `Location` string exactly. Coords =
// the feed's own Address block, Nominatim-checked (the old hand-typed
// Angus Glen point was 2.4 km off). Unlisted venues are learned from
// the feed at run time.
venues: {
'Angus Glen Community Centre': { address: '3990 Major Mackenzie Dr E', district: 'Markham', postalCode: 'L6C 1P8', lat: 43.895354, lng: -79.336539 },
'Crosby Community Centre': { address: '210 Main St Unionville', district: 'Markham', postalCode: 'L3R 2G9', lat: 43.868535, lng: -79.313304 },
'Milliken Mills Community Centre': { address: '7600 Kennedy Rd', district: 'Markham', postalCode: 'L3R 9S5', lat: 43.840228, lng: -79.305006 },
'Thornhill Community Centre': { address: '7755 Bayview Ave', district: 'Markham', postalCode: 'L3T 4P1', lat: 43.820032, lng: -79.399873 },
'Markham Village Community Centre': { address: '6041 Highway 7', district: 'Markham', postalCode: 'L3P 3A7', lat: 43.873298, lng: -79.258153 },
'Mount Joy Community Centre': { address: '6140 16th Ave E', district: 'Markham', postalCode: 'L3P 3K8', lat: 43.895012, lng: -79.262293 }
},
defaultDistrict: 'Markham',
registrationUrl: () => 'https://cityofmarkham.perfectmind.com/Clients/BookMe4BookingPages/Classes?calendarId=ecf5202d-4c97-4f89-b4e3-42966a1cc453&widgetId=6825ea71-e5b7-4c2a-948f-9195507ad90a&embed=False',
infoUrl: 'https://www.markham.ca/sports-recreation-fitness/sports-recreation-programs/programs/drop-programs'
},
'vaughan': {
kind: 'perfectmind',
// City of Vaughan — PerfectMind org 25076, calendar "Skating & Shinny
// Hockey". Drop-in skating and shinny are FREE ("No fee" → $0, so
// rows aren't hidden behind the Paid toggle); Ticket Ice figure
// skating is $10.50 and keeps its own price. ~85 sessions/week →
// 3-day windows stay under the ClassesV2 truncation.
base: 'https://vaughan.perfectmind.com/25076',
widgetId: 'dff88c8a-0b78-4a94-9dde-250040385300',
calendarId: '84142d20-1da8-48ed-800d-31dc0c30121d',
daysAhead: 28,
windowDays: 3,
paid: true,
// The booking page lists every tier as "Free", but that is the resident
// rate; non-residents pay at the desk. Say so on every free row.
priceNote: (price) => price === 0 ? 'Free for Vaughan residents · non-residents: ask at the desk (city fees carry a 20% non-resident surcharge)' : '',
venues: {
'Al Palladini Community Centre': { address: '9201 Islington Ave', district: 'Vaughan', postalCode: 'L4L 1A7', lat: 43.816528, lng: -79.597205 },
'Rosemount Community Centre': { address: '1000 New Westminster Dr', district: 'Vaughan', postalCode: 'L4J 8G3', lat: 43.817858, lng: -79.453424 },
'Garnet A. Williams Community Centre': { address: '501 Clark Ave W', district: 'Vaughan', postalCode: 'L4J 4E5', lat: 43.803966, lng: -79.439973 },
'Woodbridge Pool & Memorial Arena': { address: '5020 Highway 7', district: 'Vaughan', postalCode: 'L4L 1T1', lat: 43.7802455, lng: -79.5916837 },
'Maple Community Centre': { address: '10190 Keele St', district: 'Vaughan', postalCode: 'L6A 1R7', lat: 43.859882, lng: -79.514834 }
},
defaultDistrict: 'Vaughan',
registrationUrl: () => 'https://vaughan.perfectmind.com/25076/Clients/BookMe4BookingPages/Classes?calendarId=84142d20-1da8-48ed-800d-31dc0c30121d&widgetId=dff88c8a-0b78-4a94-9dde-250040385300&embed=False',
infoUrl: 'https://www.vaughan.ca/residential/recreation-programs-and-fitness/skating-hockey'
},
'richmondhill': {
kind: 'activenet',
// City of Richmond Hill — ActiveNet online calendar 12 "Skating &
// Shinny", which carries Ed Sackfield Arena only (the other arenas'
// drop-ins exist as weekly activity patterns, not calendar rows —
// not wired yet). The calendar period rolls ~3 weeks ahead.
base: 'https://anc.ca.apm.activecommunities.com/richmondhill/rest',
calendarId: 12,
categoryIds: [],
centerIds: [27],
daysAhead: 28,
paid: true, // tickets at the arena desk from 30 min before (not sold online)
programs: [
{ match: /public skating/i, activity: 'Public Skating', defaultPrice: 5.90 },
{ match: /40\+.*shinny/i, activity: 'Shinny 40+', defaultPrice: 8.70, ageMin: 40 },
{ match: /shinny/i, activity: 'Shinny', defaultPrice: 8.70, ageMin: 18 },
{ match: /stick (and|&) puck/i, activity: 'Stick & Puck', defaultPrice: 8.70 },
{ match: /figure skat/i, activity: 'Figure Skating (drop-in)', defaultPrice: 8.70 },
{ match: /ringette/i, activity: 'Ringette (drop-in)', defaultPrice: 8.70 }
],
venues: {
'Ed Sackfield Arena and Fitness Centre': { address: '311 Valleymede Dr', district: 'Richmond Hill', postalCode: 'L4B 2E1', lat: 43.8571076, lng: -79.3989503 }
},
defaultDistrict: 'Richmond Hill',
registrationUrl: () => 'https://www.richmondhill.ca/en/things-to-do/Skating.aspx',
infoUrl: 'https://www.richmondhill.ca/en/things-to-do/Skating.aspx'
},
'brampton': {
kind: 'perfectmind',
// City of Brampton — PerfectMind org 23782, calendar "Drop-In → Skating".
// Busiest calendar around (up to 32 sessions/day): 1-day windows keep
// every request under the server's ~50-class truncation (see
// fetchPerfectMind). Feed carries venue coordinates; venues{} below
// is the rink-inventory list + override.
base: 'https://cityofbrampton.perfectmind.com/23782',
widgetId: '15f6af07-39c5-473e-b053-96653f77a406',
calendarId: '66a6c983-2ead-42a9-8445-d926fa974fbf',
daysAhead: 28,
windowDays: 1,
include: /public skate/i, // drop shinny / shoot-around / figure
cleanName: (n) => n.replace(/\s*\|.*$/, ''), // "Public Skate Drop-In (All Ages) | Susan Fennell 12:00-12:50pm"
paid: true, // adult $2.96 + tax; 65+ residents free
venues: {
'Cassie Campbell Community Centre': { address: '1050 Sandalwood Pkwy W', district: 'Brampton', postalCode: 'L7A 0K9', lat: 43.696632, lng: -79.824842 },
'Century Gardens Recreation Centre': { address: '340 Vodden St E', district: 'Brampton', postalCode: 'L6V 2N2', lat: 43.708429, lng: -79.753575 },
'Earnscliffe Recreation Centre': { address: '44 Eastbourne Dr', district: 'Brampton', postalCode: 'L6T 2B2', lat: 43.723334, lng: -79.699493 },
'Greenbriar Recreation Centre': { address: '1100 Central Park Dr', district: 'Brampton', postalCode: 'L6S 2C9', lat: 43.735986, lng: -79.717288 },
'Jim Archdekin Recreation Centre': { address: '292 Conestoga Dr', district: 'Brampton', postalCode: 'L6Z 3M1', lat: 43.71733, lng: -79.789659 },
"Susan Fennell Sportsplex (South Fletcher's)": { address: '500 Ray Lawson Blvd', district: 'Brampton', postalCode: 'L6Y 5B3', lat: 43.652793, lng: -79.735956 },
'Terry Miller Recreation Centre': { address: '1295 Williams Pkwy', district: 'Brampton', postalCode: 'L6S 3J8', lat: 43.73265, lng: -79.730347 }
},
defaultDistrict: 'Brampton',
registrationUrl: () => 'https://cityofbrampton.perfectmind.com/23782/Clients/BookMe4BookingPages/Classes?calendarId=66a6c983-2ead-42a9-8445-d926fa974fbf&widgetId=15f6af07-39c5-473e-b053-96653f77a406&embed=False',
infoUrl: 'https://www.brampton.ca/EN/residents/Recreation/Programs-Activities/Pages/Skating.aspx'
},
'oakville': {
kind: 'perfectmind',
// Town of Oakville — PerfectMind org 24974, calendar "Recreational
// Skating and Shinny Hockey" (shinny filtered out by `include`).
base: 'https://townofoakville.perfectmind.com/24974',
widgetId: 'e621581b-5db2-4635-a887-4f02b9585807',
calendarId: '332fd288-9a60-4b8f-9b7b-373c4e1bed5d',
daysAhead: 28,
windowDays: 7,
include: /recreation(al)?\s+skat/i,
paid: true, // adult $5.38, child/youth/65+ $4.31 + tax
venues: {
'Glen Abbey Community Centre': { address: '1415 Third Line', district: 'Oakville', postalCode: 'L6M 3G2', lat: 43.435554, lng: -79.739041 },
"Joshua's Creek Arenas": { address: '1663 North Service Rd E', district: 'Oakville', postalCode: 'L6H 7G5', lat: 43.491731, lng: -79.676112 },
'Kinoak Arena': { address: '363 Warminster Dr', district: 'Oakville', postalCode: 'L6L 4N1', lat: 43.4198, lng: -79.699932 },
'Maple Grove Arena': { address: '2237 Devon Rd', district: 'Oakville', postalCode: 'L6J 5M1', lat: 43.479078, lng: -79.643756 },
'River Oaks Community Centre': { address: '2400 Sixth Line', district: 'Oakville', postalCode: 'L6H 3N8', lat: 43.47104, lng: -79.72358 },
'Sixteen Mile Sports Complex': { address: '3070 Neyagawa Blvd', district: 'Oakville', postalCode: 'L6M 4L6', lat: 43.465913, lng: -79.749331 },
'Trafalgar Park Community Centre': { address: '133 Rebecca St', district: 'Oakville', postalCode: 'L6K 1J4', lat: 43.439878, lng: -79.677705 }
},
venueAliases: { 'Trafalgar Park Community Centre-133 Rebecca': 'Trafalgar Park Community Centre' },
defaultDistrict: 'Oakville',
registrationUrl: () => 'https://townofoakville.perfectmind.com/24974/Clients/BookMe4BookingPages/Classes?calendarId=332fd288-9a60-4b8f-9b7b-373c4e1bed5d&widgetId=e621581b-5db2-4635-a887-4f02b9585807&embed=False',
infoUrl: 'https://www.oakville.ca/parks-recreation-culture/programs-activities/skating/'
},
'burlington': {
kind: 'perfectmind',
// City of Burlington — PerfectMind org 22818; one shared calendar
// holds skating + sticks-and-pucks (filtered by `include`).
base: 'https://cityofburlington.perfectmind.com/22818',
widgetId: '8d8b4749-9c4e-4762-be93-fe54f1e1203b',
calendarId: '517e0420-1478-458e-8a6f-ad813e278ec0',
daysAhead: 28,
windowDays: 3,
include: /^(public skate|skate 19\+|sensory skate)/i,
paid: true, // flat $3.50 (pass holders $0)
venues: {
'Aldershot Arena': { address: '494 Townsend Ave', district: 'Burlington', postalCode: 'L7T 2B3', lat: 43.316465, lng: -79.833132 },
'Appleby Ice Centre': { address: '1201 Appleby Line', district: 'Burlington', postalCode: 'L7S 1E4', lat: 43.38674, lng: -79.775557 },
'Central Arena': { address: '519 Drury Lane', district: 'Burlington', postalCode: 'L7R 2H2', lat: 43.335167, lng: -79.792986 },
'Mainway Ice Centre': { address: '4015 Mainway', district: 'Burlington', postalCode: 'L7P 3N9', lat: 43.372749, lng: -79.795877 },
'Mountainside Community Centre': { address: '2205 Mount Forest Dr', district: 'Burlington', postalCode: 'L7P 1H4', lat: 43.352526, lng: -79.822977 },
'Nelson Arena': { address: '4235 New St', district: 'Burlington', postalCode: 'L7L 5M9', lat: 43.360767, lng: -79.763181 },
'Skyway Community Centre': { address: '129 Kenwood Ave', district: 'Burlington', postalCode: 'L7M 1V8', lat: 43.368761, lng: -79.733246 }
},
defaultDistrict: 'Burlington',
registrationUrl: () => 'https://cityofburlington.perfectmind.com/22818/Clients/BookMe4BookingPages/Classes?calendarId=517e0420-1478-458e-8a6f-ad813e278ec0&widgetId=8d8b4749-9c4e-4762-be93-fe54f1e1203b&embed=False',
infoUrl: 'https://www.burlington.ca/en/recreation/skating.aspx'
},
'mississauga': {
kind: 'activenet',
// City of Mississauga — ActiveNet online calendar (calendar 1 = "Drop
// In Programs", category 52 = "Skating & Hockey"). One POST returns
// every dated session for the whole season across the listed arenas;
// the server ignores date bounds, so we window client-side. No CORS.
base: 'https://anc.ca.apm.activecommunities.com/activemississauga/rest',
calendarId: 1,
categoryIds: [52],
centerIds: [290, 248, 240, 250, 252, 253, 100, 82, 128, 106],
daysAhead: 28,
paid: true,
// Prices aren't in the API (only a free flag) → 2026 by-law rates + HST.
programs: [
{ match: /^At Play-?\s*Fun Skate$/i, activity: 'Fun Skate (Youth 10–17, free)', defaultPrice: 0, ageMin: 10, ageMax: 17 },
{ match: /^Fun Skate$/i, activity: 'Fun Skate', defaultPrice: 5.21, ageMin: 3 },
{ match: /Adult & Older Adult Skate/i, activity: 'Adult & Older Adult Skate', defaultPrice: 5.21, ageMin: 18 },
{ match: /Adult Skate Fit/i, activity: 'Adult Skate Fit', defaultPrice: 19.46, ageMin: 18 }
],
venues: {
'Burnhamthorpe Community Centre': { address: '1500 Gulleden Dr', district: 'Mississauga', postalCode: 'L4X 2T7', lat: 43.6227, lng: -79.5988 },
'Carmen Corbasson Community Centre': { address: '1399 Cawthra Rd', district: 'Mississauga', postalCode: 'L5G 4L1', lat: 43.5782, lng: -79.5767 },
'Clarkson Community Centre': { address: '2475 Truscott Dr', district: 'Mississauga', postalCode: 'L5J 2B3', lat: 43.5116, lng: -79.6503 },
'Erin Mills Twin Arena': { address: '3205 Unity Dr', district: 'Mississauga', postalCode: 'L5L 4L5', lat: 43.5371, lng: -79.7116 },
'Huron Park Recreation Centre': { address: '830 Paisley Blvd W', district: 'Mississauga', postalCode: 'L5C 3P5', lat: 43.5591, lng: -79.6331 },
'Iceland Arena': { address: '705 Matheson Blvd E', district: 'Mississauga', postalCode: 'L4Z 3X9', lat: 43.628, lng: -79.6494 },
'Meadowvale 4 Rinks': { address: '2160 Torquay Mews', district: 'Mississauga', postalCode: 'L5N 2M6', lat: 43.5952, lng: -79.7409 },
'Mississauga Valley Community Centre': { address: '1275 Mississauga Valley Blvd', district: 'Mississauga', postalCode: 'L5A 3R8', lat: 43.597, lng: -79.6239 },
'Paul Coffey Arena': { address: '6990 Goreway Dr', district: 'Mississauga', postalCode: 'L4T 1A9', lat: 43.7125, lng: -79.6311 },
'Port Credit Memorial Arena': { address: '40 Stavebank Rd', district: 'Mississauga', postalCode: 'L5G 2T8', lat: 43.553, lng: -79.589 }
},
defaultDistrict: 'Mississauga',
// No per-session online booking (tickets sold 30 min before) — the
// fetcher deep-links each event's own activity page instead.
registrationUrl: () => 'https://anc.ca.apm.activecommunities.com/activemississauga/activity/search?activity_select_param=2&activity_keyword=skate',
infoUrl: 'https://www.mississauga.ca/recreation-and-sports/sports-and-activities/skating-and-hockey/'
},
'mosspark': {
kind: 'scrape',
url: 'https://mossparkarena.com/home/skating/public-skating/',
daysAhead: 28,
activity: 'Public Skating (Free)',
locationName: 'Moss Park Arena',
// City locationid 3491 (board-operated arena) — lets the client
// match toronto.ca service alerts even though the schedule is scraped.
locationId: 3491,
address: '140 Sherbourne St',
district: 'Toronto and East York',
postalCode: 'M5A 3S5',
lat: 43.6550295646, lng: -79.3702954334,
paid: false,
unverified: true, // schedule comes from their website, no live feed → tell users to double-check
infoUrl: 'https://mossparkarena.com/home/skating/public-skating/'
},
/* ---- Toronto's board-run arenas: City-owned, own websites, never in the City export ---- */
'grossman': {
kind: 'scrape',
url: 'https://larrygrossmanforesthillarena.com/recreational-skating-hours/',
daysAhead: 28,
activity: 'Pleasure Skate',
locationName: 'Larry Grossman Forest Hill Memorial Arena',
locationId: 3489,
address: '340 Chaplin Cres', district: 'Toronto and East York', postalCode: 'M5P 1A9',
lat: 43.7042503753, lng: -79.4201852796,
season: { from: '10-01', to: '04-30' },
paid: true, price: 3, priceNote: '$3 per person, pay at the door',
unverified: true,
infoUrl: 'https://larrygrossmanforesthillarena.com/recreational-skating-hours/'
},
'leaside-public': {
kind: 'scrape',
url: 'https://leasidegardens.com/arenas/public-skate-times/',
section: { from: /Public Skating Drop-In/i, to: /Adult Shinny/i },
only: /ADULTS ONLY/i,
daysAhead: 28,
activity: 'Public Skating: Adults 18+',
ageMin: 18,
locationName: 'Leaside Memorial Community Gardens',
locationId: 789,
address: '1073 Millwood Rd', district: 'Toronto and East York', postalCode: 'M4G 1X6',
lat: 43.7016796841, lng: -79.3612191537,
paid: false, unverified: true,
infoUrl: 'https://leasidegardens.com/arenas/public-skate-times/'
},
'leaside-community': {
kind: 'scrape',
url: 'https://leasidegardens.com/arenas/public-skate-times/',
section: { from: /Public Skating Drop-In/i, to: /Adult Shinny/i },
only: /Community Skate/i,
daysAhead: 28,
activity: 'Community Skate: All Ages',
locationName: 'Leaside Memorial Community Gardens',
locationId: 789,
address: '1073 Millwood Rd', district: 'Toronto and East York', postalCode: 'M4G 1X6',
lat: 43.7016796841, lng: -79.3612191537,
paid: false, unverified: true,
infoUrl: 'https://leasidegardens.com/arenas/public-skate-times/'
},
'leaside-pickuphub': {
kind: 'pickuphub',
slug: 'leaside-gardens', // https://pickuphub.net/leaside-gardens
daysAhead: 35,
// PickupHub sport slug → how the app names the session (a pad name is appended)
activities: { 'figure-skating': 'Figure Skating Ticket Ice', 'ice-hockey': 'Adult Shinny' },
ageMin: 18,
locationName: 'Leaside Memorial Community Gardens',
locationId: 789,
address: '1073 Millwood Rd', district: 'Toronto and East York', postalCode: 'M4G 1X6',
lat: 43.7016796841, lng: -79.3612191537,
paid: true, priceNote: 'HST included · register on PickupHub, sessions fill up',
infoUrl: 'https://pickuphub.net/leaside-gardens'
},
'bolton-public': {
kind: 'scrape',
url: 'https://www.billboltonarena.ca/public-skating',
section: { from: /General Public/i, to: /Adults Only/i },
daysAhead: 28,
activity: 'Public Skating (Free)',
locationName: 'William H. Bolton Arena',
locationId: 3495,
address: '40 Rossmore Rd', district: 'Toronto and East York', postalCode: 'M6G 4C7',
lat: 43.6711959361, lng: -79.4152061268,
paid: false, unverified: true,
infoUrl: 'https://www.billboltonarena.ca/public-skating'
},
'bolton-adult': {
kind: 'scrape',
url: 'https://www.billboltonarena.ca/public-skating',
section: { from: /Adults Only/i, to: /Parent/i },
daysAhead: 28,
activity: 'Adults Only Skate (18+)',
ageMin: 18,
locationName: 'William H. Bolton Arena',
locationId: 3495,
address: '40 Rossmore Rd', district: 'Toronto and East York', postalCode: 'M6G 4C7',
lat: 43.6711959361, lng: -79.4152061268,
paid: false, unverified: true,
infoUrl: 'https://www.billboltonarena.ca/public-skating'
},
'bolton-tots': {
kind: 'scrape',
url: 'https://www.billboltonarena.ca/public-skating',
section: { from: /Parent\s*&\s*Tots?/i, to: /rental/i },
daysAhead: 28,
activity: 'Parent & Tots Skate (4 and under)',
ageMax: 4,
locationName: 'William H. Bolton Arena',
locationId: 3495,
address: '40 Rossmore Rd', district: 'Toronto and East York', postalCode: 'M6G 4C7',
lat: 43.6711959361, lng: -79.4152061268,
paid: false, unverified: true,
infoUrl: 'https://www.billboltonarena.ca/public-skating'
},
/* ---- York & Durham cities without an API (see skate/docs/data-sources/york-durham.md) ---- */
'stouffville': {
kind: 'pdf',
layout: 'weekday-grid',
// Whitchurch-Stouffville publishes its drop-in sheet as a PDF (grid:
// Activity | Age | Monday … Sunday, one block per arena). The Town's
// ActiveNet "Skating & Shinny" calendar (3) is checked first — it
// was published empty for fall 2026 but is the better source if it
// ever fills up. The PDF's path changes per revision → discovered
// from the drop-in page each run; pdfUrl is only the fallback.
activenet: { base: 'https://anc.ca.apm.activecommunities.com/townofws/rest', calendarId: 3, centerIds: [13, 14] },
discover: {
page: 'https://www.townofws.ca/play/recreation/programs/drop-in-programs/',
hops: [/href="([^"]*\/media\/[^"]*skating-[^"]*dropin[^"]*\.pdf)"/i]
},
pdfUrl: 'https://www.townofws.ca/media/zkrbulbm/skating-f26_dropin_sep9.pdf',
grid: { stopAt: /Important Information/i },
daysAhead: 28,
paid: true,
// Adult drop-in fees incl. HST (fall 2026 sheet): skate $5.50, stick & puck / shinny $7.50.
programs: [
{ match: /shinny|stick/i, price: 7.50 },
{ match: /./, price: 5.50 }
],
priceNote: 'Adult price at the door (15 min before) · youth & 60+ $3.50 / $5.50, tots $2.50 / $4.50, family $12.50 / $17.50',
venues: {
'Stouffville Clippers Sports Complex': { address: '120 Weldon Rd', district: 'Stouffville', postalCode: 'L4A 1N2', lat: 43.9650556, lng: -79.2617345 },
'Stouffville Arena': { address: '12483 Ninth Line', district: 'Stouffville', postalCode: 'L4A 1J3', lat: 43.9742243, lng: -79.2553137 }
},
district: 'Stouffville',
unverified: true, // PDF, no live feed — check before a long drive
infoUrl: 'https://www.townofws.ca/play/recreation/programs/drop-in-programs/'
},
'ajax': {
kind: 'pdf',
layout: 'weekday-lines',
// ajax.ca → Publitas flipbook → its PDF: three plain GETs. The
// flipbook name embeds the season, so it is rediscovered each run.
discover: {
page: 'https://ajax.ca/explore/parks-recreation/sports-recreation/skating/',
hops: [
/(https:\/\/view\.publitas\.com\/ajax\/skating-schedule[^"'\s<>]*)/i,
/(https:\/\/view\.publitas\.com\/\d+\/\d+\/pdfs\/[^"]+\.pdf[^"]*)/i
]
},
daysAhead: 28,
paid: true,
locationName: 'Ajax Community Centre',
// Fall 2026 admissions: skating adult $5.25 (youth & 65+ $3.50), shinny/sledge adult $7.90 (youth & 65+ $5.65).
programs: [
{ match: /ticket ice/i, skip: true }, // Skate Canada members with a coach only
{ match: /parent\s*(?:&|and)\s*tot/i, activity: 'Parent & Tot Skate', ageMax: 5, price: 5.25 },
{ match: /public skat/i, activity: 'Public Skating', price: 5.25 },
{ match: /adult skate/i, activity: 'Adult Skate', ageMin: 18, price: 5.25 },
{ match: /stick\s*(?:n|&|and)\s*puck/i, activity: 'Adult Stick & Puck', ageMin: 18, price: 5.25 },
{ match: /ladies shinny/i, activity: 'Ladies Shinny', ageMin: 18, price: 7.90 },
{ match: /sledge/i, activity: 'Sledge Shinny (adapted)', price: 7.90 },
{ match: /shinny/i, activity: 'Adult Shinny', ageMin: 18, price: 7.90 }
],
priceNote: 'Adult price · youth & 65+ $3.50 (shinny $5.65) · 3 & under free · pay at the desk',
venues: {
'Ajax Community Centre': { address: '75 Centennial Rd', district: 'Ajax', postalCode: 'L1S 4L1', lat: 43.8393724, lng: -79.0206406 }
},
district: 'Ajax',
unverified: true,
infoUrl: 'https://ajax.ca/explore/parks-recreation/sports-recreation/skating/'
},
'oshawa': {
kind: 'intelligenz',
// "activeOshawa Online" (Intelligenz) behind a Queue-it gate: one
// VenueClasses page per arena carries every dated session for the
// window. Harman Park Arena (829 Douglas St, 43.8786429,
// -78.8469186) had no fall ice sessions published yet — add its GUID
// (first row's VenueClasses link on category SKATEHP) when it does.
base: 'https://register.oshawa.ca/OSHAWA',
daysAhead: 28,
paid: true,
activityMatch: /skate|shinny|stick|ticket ice/i,
venues: {
'Delpark Homes Centre': { guid: '0c780b98-9e2c-4f79-a226-a6106d010224', address: '1661 Harmony Rd N', district: 'Oshawa', postalCode: 'L1H 7K5', lat: 43.9485033, lng: -78.8512963 },
'Donevan Recreation Complex': { guid: '96bac799-82c0-45ed-b6ab-065a198d48a5', address: '171 Harmony Rd S', district: 'Oshawa', postalCode: 'L1H 6T9', lat: 43.8999743, lng: -78.8305996 }
},
// 2026 admissions: leisure skate adult $5.25, shinny adult $8.50, figure/ticket ice $11.25.
programs: [
{ match: /ticket ice/i, activity: 'Ticket Ice (figure skating practice)', price: 11.25 },
{ match: /shinny/i, price: 8.50 },
{ match: /./, price: 5.25 }
],
priceNote: 'Adult price · child/youth/student $3.50, family $10.75, Oshawa 55+ $1.50 · shinny youth $6.50 · pay at the desk',
district: 'Oshawa',
registrationUrl: () => 'https://register.oshawa.ca/OSHAWA/public/category/browse/SKATEDHC',
infoUrl: 'https://www.oshawa.ca/explore-play/recreation/hockey-and-skating/leisure-skating/'
},
'pickering': {
kind: 'html-grid',
// pickering.ca embeds the season's grids as plain tables (row =
// weekday, column = program) with the season dates and cancellation
// list in the text around them. Admission is free.
url: 'https://www.pickering.ca/parks-recreation-culture/arenas-and-skating/',
daysAhead: 28,
paid: false,
venues: {
'Chestnut Hill Developments Recreation Complex': { address: '1867 Valley Farm Rd', district: 'Pickering', postalCode: 'L1V 3Y7', lat: 43.8392345, lng: -79.0814572 },
'Don Beer Arena': { address: '940 Dillingham Rd', district: 'Pickering', postalCode: 'L1W 1Z6', lat: 43.8246758, lng: -79.0671679 }
},
venueAliases: { 'CHD Rec Complex': 'Chestnut Hill Developments Recreation Complex', 'CHDRC': 'Chestnut Hill Developments Recreation Complex' },
programs: [
{ match: /^daytime/i, activity: 'Daytime Skate' },
{ match: /^public/i, activity: 'Public Skate' },
{ match: /p\s*&\s*c stick/i, activity: 'Parent & Child Stick & Puck' },
{ match: /p\s*&\s*c skate/i, activity: 'Parent & Child Skate' }
],
seasons: [
{ label: /Daytime Skating:/i, match: /daytime/i },
{ label: /Parent\s*&\s*Child\s*\/\s*Stick\s*&\s*Puck:/i, match: /parent & child/i },
{ label: /Public Skating:/i, match: /public skate/i }
],
cancelGroups: [
{ label: /Public Skating:/i, match: /public skate/i, matchSample: 'Public Skate' },
{ label: /Daytime Skating:/i, match: /daytime/i, matchSample: 'Daytime Skate' },
{ label: /Parent\s*&\s*Child/i, match: /parent & child/i, matchSample: 'Parent & Child Skate' }
],
district: 'Pickering',
unverified: true,
infoUrl: 'https://www.pickering.ca/parks-recreation-culture/arenas-and-skating/'
}
};
const RINK_PACKAGES = [
{ id: 'indoor-ice-rinks', kind: 'indoor' },
{ id: 'outdoor-artificial-ice-rinks', kind: 'outdoor' }
];
const ALERTS_URL = 'https://www.toronto.ca/data/parks/live/skate_allupdates.json';
// Per-location live feeds behind toronto.ca's facility pages (UTF-16 JSON!)
const LIVE_LOCATIONS_URL = 'https://www.toronto.ca/data/parks/live/locations/';
const LIVE_CHECK_DAYS = 14; // this week + next (weekN.json files are Monday-based)
const LIVE_REQUEST_GAP_MS = 80; // be a polite guest on toronto.ca
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
/* ================= HTTP helpers ================= */
/** GET url → raw text (follows up to 3 redirects, no deps). */
function httpGetText(url, headers = {}, redirects = 0) {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { 'User-Agent': 'toronto-skating-site-data-fetcher', ...headers } }, (res) => {
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && redirects < 3) {
res.resume();
return resolve(httpGetText(new URL(res.headers.location, url).href, headers, redirects + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} for ${url.substring(0, 90)}`));
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(decodeBody(Buffer.concat(chunks))));
res.on('error', reject);
});
req.on('error', reject);
req.setTimeout(45000, () => req.destroy(new Error(`Timeout for ${url.substring(0, 90)}`)));
});
}
/**
* Bytes → string, honouring a BOM. toronto.ca's per-location live feeds
* are served as UTF-16LE with a BOM (JSON.parse chokes on them as UTF-8);
* everything else is plain UTF-8.
*/
function decodeBody(buf) {
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return new TextDecoder('utf-16le').decode(buf.subarray(2));
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) return new TextDecoder('utf-16be').decode(buf.subarray(2));
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return buf.subarray(3).toString('utf8');
return buf.toString('utf8');
}
/**
* Make HTTPS request and return JSON
*/
async function fetchJSON(url, headers = {}) {
console.log(`Fetching: ${url.substring(0, 80)}...`);
const text = await httpGetText(url, headers);
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`Failed to parse JSON: ${e.message}`);
}
}
/** POST form-encoded → JSON (PerfectMind-style widget endpoints). */
function httpPostJSON(url, formBody, headers = {}) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams(formBody).toString();
const req = https.request(url, {
method: 'POST',
headers: {
'User-Agent': 'toronto-skating-site-data-fetcher',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Content-Length': Buffer.byteLength(body),
...headers
}
}, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} for POST ${url.substring(0, 80)}`));
}
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error(`Failed to parse JSON: ${e.message}`)); }
});
res.on('error', reject);
});
req.on('error', reject);
req.setTimeout(45000, () => req.destroy(new Error(`Timeout for POST ${url.substring(0, 80)}`)));
req.write(body);
req.end();
});
}
/** POST a JSON body → JSON (ActiveNet REST). */
function httpPostJSONBody(url, obj, headers = {}) {
return new Promise((resolve, reject) => {
const body = JSON.stringify(obj);
const req = https.request(url, {
method: 'POST',
headers: {
'User-Agent': 'toronto-skating-site-data-fetcher',
'Content-Type': 'application/json;charset=UTF-8',
'Accept': 'application/json',
'Content-Length': Buffer.byteLength(body),
...headers
}
}, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} for POST ${url.substring(0, 80)}`));
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(decodeBody(Buffer.concat(chunks)))); }
catch (e) { reject(new Error(`Failed to parse JSON: ${e.message}`)); }
});
res.on('error', reject);
});
req.on('error', reject);
req.setTimeout(60000, () => req.destroy(new Error(`Timeout for POST ${url.substring(0, 80)}`)));
req.write(body);
req.end();
});
}
/* ================= Toronto-time helpers (CI runs in UTC) ================= */
const TORONTO_TZ = 'America/Toronto';
/** 'YYYY-MM-DD' for a Date, in Toronto local time. */
function torontoDateStr(d = new Date()) {
return new Intl.DateTimeFormat('en-CA', { timeZone: TORONTO_TZ, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
}
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
/** Weekday name for a 'YYYY-MM-DD' string (date-only math, no TZ drift). */
function weekdayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
}
/** dateStr + n days → 'YYYY-MM-DD' (date-only math). */
function addDays(dateStr, n) {
const [y, m, d] = dateStr.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + n));
return dt.toISOString().slice(0, 10);
}
/* ================= City drop-in programs (existing pipeline) ================= */
/**
* Fetch all records from a datastore resource with pagination
*/
async function fetchAllRecords(resourceId, resourceName) {
let allRecords = [];
let offset = 0;
const limit = 10000; // Larger batch for efficiency
let total = Infinity;
console.log(`\n📥 Fetching ${resourceName}...`);
while (offset < total) {
const url = `${BASE_URL}/datastore_search?id=${resourceId}&limit=${limit}&offset=${offset}`;
const result = await fetchJSON(url);
if (result.success) {
allRecords = allRecords.concat(result.result.records);
total = result.result.total;
console.log(` Progress: ${allRecords.length}/${total} records`);
} else {
throw new Error(`API returned error for ${resourceName}`);
}
offset += limit;
// Small delay to be nice to the server
await new Promise(r => setTimeout(r, 100));
}
console.log(` ✅ Complete: ${allRecords.length} records`);
return allRecords;
}
/**
* Filter for ICE-skating programs only.
*
* The city's ice programs all live under Section "Skate - Drop-In" /
* "Skating - Drop-In"; the title keywords are only a fallback in case a
* future section is named differently. NON_ICE guards the fallback from
* gym/pavement sports that share hockey vocabulary — "Ball Hockey" alone
* was 78 records of bloat on an ice-skating site.
*/
const NON_ICE = /(ball|floor|street|road|dek|cosom)\s*hockey|skateboard|inline|roller/i;
function filterSkatingPrograms(programs) {
return programs.filter(p => {
// Field names from the actual API
const courseTitle = (p['Course Title'] || '').toLowerCase();
const section = (p['Section'] || '').toLowerCase();
if (NON_ICE.test(courseTitle) || NON_ICE.test(section)) return false;
return section.includes('skat') ||
courseTitle.includes('skate') ||
courseTitle.includes('shinny') ||
courseTitle.includes('hockey') ||
courseTitle.includes('ringette') ||
courseTitle.includes('stick and puck');
});
}
/** "13" → 13, "None"/""/garbage → null (the raw feed mixes all three). */
function cleanAge(v) {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : null;
}
/* ================= Rink inventory → rinks.json ================= */
/**
* "McGREGOR PARK COMMUNITY CENTRE" → "McGregor Park Community Centre".
* Shouty = four letters in five are capitals (the City's asset names keep a
* lowercase c in "McGREGOR", so an all-caps test let it through); Mc names
* and O'Names keep their inner capital, small words stay small.
*/
function titleCaseIfShouty(s) {
if (!s) return '';
// "Mccowan" → "McCowan" whatever the rest looks like (the City writes it both ways)
const mc = (x) => x.replace(/\bMc([a-z])/g, (m, ch) => 'Mc' + ch.toUpperCase());
const letters = s.replace(/[^A-Za-z]/g, '');
if (letters.length < 4 || letters.replace(/[^A-Z]/g, '').length < letters.length * 0.8) return mc(s);
const SMALL = new Set(['of', 'and', 'the', 'at', 'on', 'in', 'de', 'du', 'des', 'la', 'le']);
return mc(s.toLowerCase()
.replace(/(^|[\s\-\/('.])([a-z])/g, (m, pre, ch) => pre + ch.toUpperCase()))
.replace(/\s([A-Z][a-z]+)\b/g, (m, w) => SMALL.has(w.toLowerCase()) ? ' ' + w.toLowerCase() : m);
}
/**
* Fetch indoor + outdoor rink datasets and group pads by city locationid.
* Gives the client: coordinates (nearest-rink locator), pad kinds
* (indoor/outdoor — needed to judge whether a "Closed for the Season"
* alert on the outdoor pad affects an indoor program), and the full rink
* universe including rinks with no scheduled drop-ins.
*/
/**
* City drop-in locations the rink datasets do not list (Scarborough Arena
* Gardens, Park Lawn Park…) still deserve a pin and a count. Their address
* comes from the program rows; coordinates via Nominatim, cached in
* projects/data/geocode-cache.json so the lookup happens once per address.
*/
const GEOCODE_CACHE = path.join(OUTPUT_DIR, 'geocode-cache.json');
async function geocodeAddress(query) {
let cache = {};
try { cache = JSON.parse(fs.readFileSync(GEOCODE_CACHE, 'utf8')); } catch { /* first run */ }
const key = query.toLowerCase().replace(/\s+/g, ' ').trim();
if (cache[key]) return cache[key];
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&countrycodes=ca&q=${encodeURIComponent(query)}`;
const arr = JSON.parse(await httpGetText(url, { Accept: 'application/json' }));
await new Promise(r => setTimeout(r, 1100)); // Nominatim: at most one request per second
if (!Array.isArray(arr) || !arr.length) return null;
const hit = { lat: parseFloat(arr[0].lat), lng: parseFloat(arr[0].lon), label: arr[0].display_name };
cache[key] = hit;
fs.writeFileSync(GEOCODE_CACHE, JSON.stringify(cache, null, 1));
return hit;
}
async function fetchRinkInventory(programs = []) {
const byLocation = {};
for (const pkg of RINK_PACKAGES) {
const packageData = await fetchJSON(`${BASE_URL}/package_show?id=${pkg.id}`);
if (!packageData.success) throw new Error(`Failed package_show for ${pkg.id}`);
const resource = packageData.result.resources.find(r => r.datastore_active);
if (!resource) throw new Error(`No datastore resource in ${pkg.id}`);
const records = await fetchAllRecords(resource.id, `${pkg.id} pads`);
records.forEach(r => {
const locId = String(r.locationid || '').trim();
if (!locId || locId === 'None') return;
let lat = null, lng = null;
try {
const geo = JSON.parse(r.geometry);
if (geo?.coordinates) { lng = geo.coordinates[0]; lat = geo.coordinates[1]; }
} catch {}
const entry = byLocation[locId] ||= {
locationid: locId,
name: titleCaseIfShouty(r['Parent Asset Name']) || r['Public Name'] || '',
address: titleCaseIfShouty(r['Address'] || ''),
postal: r['Postal Code'] && r['Postal Code'] !== 'None' ? r['Postal Code'] : '',
district: r['Community Council Area'] || '',
lat, lng,
kinds: [],
pads: 0,
operator: r['Operated By'] || '',
source: 'city'
};
entry.pads += 1;
if (!entry.kinds.includes(pkg.kind)) entry.kinds.push(pkg.kind);
if (entry.lat == null && lat != null) { entry.lat = lat; entry.lng = lng; }
});
}
// External sources join the same universe (locator + calendar need them).
Object.entries(EXTERNAL_SOURCES).forEach(([key, cfg]) => {
// Multi-venue sources (PerfectMind calendars) emit ONE entry per
// configured venue, keyed like the program records' ExtLocationKey.
if (cfg.venues) {
const all = { ...(discoveredVenues[key] || {}), ...cfg.venues };
Object.entries(all).forEach(([name, v]) => {
const locId = venueKey(key, name);
byLocation[locId] = {
locationid: locId,
name,
address: v.address || '',
postal: v.postalCode || '',
district: v.district || cfg.defaultDistrict || '',
lat: v.lat ?? null, lng: v.lng ?? null,
kinds: ['indoor'],
pads: 1,
operator: 'External',
source: 'external',
externalSource: key,
website: cfg.infoUrl,
paid: !!cfg.paid
};
});
return;
}
const locId = cfg.locationId ? String(cfg.locationId) : `ext-${key}`;
const existing = byLocation[locId];
if (existing) {
// City already lists it (e.g. Moss Park) — just mark the external link.
existing.website = cfg.infoUrl;
existing.externalSource = key;
if (cfg.paid) existing.paid = true;
} else {
byLocation[locId] = {
locationid: locId,
name: cfg.locationName,
address: cfg.address,
postal: cfg.postalCode || '',
district: cfg.district || '',
lat: cfg.lat, lng: cfg.lng,
kinds: ['indoor'],
pads: cfg.resourceIds ? cfg.resourceIds.length : 1,
operator: 'External',
source: 'external',
externalSource: key,
website: cfg.infoUrl,
paid: !!cfg.paid
};
}
});
// City locations that only exist in the program rows
const missing = {};
programs.forEach(p => {
if (p.Source && p.Source !== 'city') return;
const id = p['Location ID'];
if (id == null || byLocation[String(id)] || missing[id]) return;
if (!p.LocationName) return;
missing[id] = p;
});
for (const [id, p] of Object.entries(missing)) {
let geo = null;
try {
geo = await geocodeAddress(`${p.Address ? p.Address + ', ' : ''}Toronto, ON${p.PostalCode ? ' ' + p.PostalCode : ''}`)
|| await geocodeAddress(`${p.LocationName}, Toronto, ON`);
} catch (e) {
console.warn(` 📍 geocode failed for ${p.LocationName}: ${e.message}`);
}
byLocation[String(id)] = {
locationid: String(id),
name: p.LocationName,
address: p.Address || '',
postal: p.PostalCode || '',
district: p.District || '',
lat: geo ? geo.lat : null, lng: geo ? geo.lng : null,
kinds: ['indoor'],
pads: 1,
operator: 'City of Toronto',
source: 'city',
fromPrograms: true
};
console.log(` 📍 added ${p.LocationName} (${id}) from the program rows${geo ? '' : ' (no coordinates yet)'}`);
}
return Object.values(byLocation).sort((a, b) => a.name.localeCompare(b.name));
}
/* ================= Service alerts → alerts.json ================= */
/**