truenorth 0.5.1 → 0.6.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 116fc6f2abc9a07b52f6b54e913596b5d7d716ca37d503fe7da2ea244b50364c
4
- data.tar.gz: 552250affcde71d62fc6220052123563cc1908ddc3696209851412884530afe9
3
+ metadata.gz: bf116b555b67f39c34c1579a5d1212ee86d26c33a2d1bc88bf681d6145728b2f
4
+ data.tar.gz: d6ed28ab2f74be19204f331e1c37a4c925c021c00689c33d0b0a56880abdb9b1
5
5
  SHA512:
6
- metadata.gz: 84e9bafbd6575a41d066f23e0ac65a369c3dce76e88b94c9dfc779323151bf46dc2678ec0b93913a9ebfc747609197b390e9cde8053388c15ee543bb7e65aad4
7
- data.tar.gz: 72328e475662ea0ca28122c8e2de29489235a42174e9a513615700fd5997646832c6cdc58c7081d3e5157fcfdaa928907c0d687b8bfdb51e4822c40f446b3661
6
+ metadata.gz: '079000943acd8e2f5a8cd278c5c20f52fe400d2df7189e6baa9aa4b64c7c814fa07f614af06fb1c6251fa302b125f8d1beb3cfa84b58287fdb04b8265706df7b'
7
+ data.tar.gz: 77a071e50db72b2c239f6b1d1beba278ff8e728aea8cd955f9a73bdd13d4535493b1412e3527fc9ebeff9fc964a8dbcff31eae2f2bf9addbf77a523965004756
@@ -28,6 +28,9 @@ module Truenorth
28
28
  )
29
29
  log 'Browser started'
30
30
 
31
+ # Auto-accept any alert dialogs (e.g., "Please create reservation for the future")
32
+ @browser.on(:dialog) { |dialog| dialog.accept }
33
+
31
34
  # Maximize window in debug mode
32
35
  if @debug && @browser
33
36
  @browser.resize(width: 1920, height: 1080)
@@ -118,451 +121,150 @@ module Truenorth
118
121
 
119
122
  navigate_to_date_and_activity(date, activity)
120
123
 
121
- # Find and click the slot
122
- slot_info = find_and_click_slot(time, court)
124
+ # Find and click the slot (dispatches mousedown+mouseup, triggers reservation AJAX)
125
+ slot_info = find_and_click_slot(time, court, date)
123
126
  raise BookingError, "No slot available at #{time}" unless slot_info
124
127
 
125
128
  log "Clicked slot: #{slot_info[:court]} at #{slot_info[:time]}"
126
129
 
127
- # Wait for dialogs to appear
128
- sleep 2
129
-
130
- # Check URL after click
131
- current_url = @browser.url
132
- page_title = @browser.title
133
- log "After slot click - URL: #{current_url}"
134
- log "After slot click - Title: #{page_title}"
135
-
136
- # Capture what dialog appeared (for debugging)
137
- if @debug
138
- dialog_info = @browser.evaluate(<<~JS)
139
- (function() {
140
- var result = {
141
- dialogsFound: 0,
142
- visibleDialogs: 0,
143
- dialogHtml: null,
144
- allDialogIds: []
145
- };
146
-
147
- var dialogs = document.querySelectorAll('.ui-dialog');
148
- result.dialogsFound = dialogs.length;
149
-
150
- for (var i = 0; i < dialogs.length; i++) {
151
- var dialog = dialogs[i];
152
- var content = dialog.querySelector('.ui-dialog-content');
153
- result.allDialogIds.push(content ? content.id : 'no-id');
154
-
155
- var style = window.getComputedStyle(dialog);
156
- if (style.display !== 'none' && style.visibility !== 'hidden') {
157
- result.visibleDialogs++;
158
- if (!result.dialogHtml) {
159
- result.dialogHtml = dialog.outerHTML;
160
- }
161
- }
162
- }
163
-
164
- return result;
165
- })()
166
- JS
167
-
168
- log "Dialog capture info: #{dialog_info['dialogsFound']} total, #{dialog_info['visibleDialogs']} visible"
169
- log "Dialog IDs: #{dialog_info['allDialogIds'].join(', ')}"
170
-
171
- if dialog_info['dialogHtml']
172
- File.write('/tmp/first_dialog.html', dialog_info['dialogHtml'])
173
- log 'Saved first dialog HTML to /tmp/first_dialog.html'
174
- else
175
- log 'WARNING: No visible dialog HTML to capture!'
176
- end
177
-
178
- @browser.screenshot(path: '/tmp/first_dialog.png')
179
- log "Saved first dialog screenshot (URL: #{@browser.url}, Title: #{@browser.title})"
180
- end
181
-
182
- # Close legends dialog if it appears
183
- legends_closed = @browser.evaluate(<<~JS)
184
- (function() {
185
- var dialogs = document.querySelectorAll('.ui-dialog');
186
- for (var i = 0; i < dialogs.length; i++) {
187
- var dialog = dialogs[i];
188
- if (dialog.style.display === 'none') continue;
189
- var legendsContent = dialog.querySelector('[id*="legends_content"]');
190
- if (legendsContent) {
191
- var closeBtn = dialog.querySelector('a.cross');
192
- if (closeBtn) {
193
- closeBtn.click();
194
- return true;
195
- }
196
- }
197
- }
198
- return false;
199
- })()
200
- JS
201
-
202
- if legends_closed
203
- log 'Closed legends dialog, clicking slot again...'
204
- sleep 1
205
-
206
- # Click the slot again to open booking dialog
207
- click_result = @browser.evaluate(<<~JS)
208
- (function() {
209
- var div = document.querySelector('div[data-area-id="' + #{slot_info[:area_id]} + '"]');
210
- if (!div) return { clicked: false, reason: 'slot not found' };
211
-
212
- var td = div.parentElement;
213
- while (td && td.tagName !== 'TD') {
214
- td = td.parentElement;
215
- }
216
- if (!td) return { clicked: false, reason: 'td not found' };
217
-
218
- // Click and immediately check for dialog or auto-booking
219
- td.click();
220
-
221
- // Wait just a tiny bit for potential dialog
222
- var startTime = Date.now();
223
- while (Date.now() - startTime < 500) {
224
- // Busy wait for 500ms
225
- }
226
-
227
- // Check what happened
228
- var dialogs = document.querySelectorAll('.ui-dialog');
229
- var visibleDialogs = 0;
230
- var saveButton = null;
231
-
232
- for (var i = 0; i < dialogs.length; i++) {
233
- var dialog = dialogs[i];
234
- var style = window.getComputedStyle(dialog);
235
- if (style.display === 'none' || style.visibility === 'hidden') continue;
236
-
237
- visibleDialogs++;
238
-
239
- // Try to find and click save button immediately
240
- var btn =
241
- dialog.querySelector('a.btn-save') ||
242
- dialog.querySelector('button.btn-save') ||
243
- dialog.querySelector('a[id*="save"]') ||
244
- dialog.querySelector('button[id*="save"]') ||
245
- dialog.querySelector('a.ui-commandlink:not(.cross)') ||
246
- dialog.querySelector('.ui-button:not(.ui-dialog-titlebar-close):not(.cross)') ||
247
- dialog.querySelector('a.ui-area-btn-success') ||
248
- dialog.querySelector('button[type="submit"]');
249
-
250
- if (btn) {
251
- btn.click();
252
- saveButton = { id: btn.id, text: btn.textContent.trim() };
253
- break;
254
- }
255
- }
256
-
257
- return {
258
- clicked: true,
259
- visibleDialogs: visibleDialogs,
260
- saveButton: saveButton,
261
- saveClicked: !!saveButton
262
- };
263
- })()
264
- JS
265
-
266
- log "Second click result: #{click_result.inspect}"
267
-
268
- if click_result['saveClicked']
269
- log "Save button clicked immediately after dialog opened!"
270
- sleep 3
271
- page_text = @browser.evaluate('document.body.textContent')
272
- if page_text =~ /confirmed|success|booked|reservation.*created/i
273
- log 'Booking appears confirmed based on page content'
274
- return {
275
- success: true,
276
- court: slot_info[:court],
277
- time: "#{slot_info[:time]} - #{(Time.parse(slot_info[:time]) + 3600).strftime('%-I:%M %p').upcase}",
278
- confirmation: 'Booking confirmed'
279
- }
280
- end
281
- end
282
-
283
- # In debug mode, wait so user can see the browser
284
- if @debug
285
- log 'DEBUG: Waiting 10 seconds so you can see the browser state...'
286
- sleep 10
287
- else
288
- sleep 2
289
- end
290
- end
291
-
292
- # Check if booking dialog opened
293
- dialog_visible = @browser.evaluate(<<~JS)
130
+ # Check if reservation panel appeared with Save button
131
+ panel_state = @browser.evaluate(<<~JS)
294
132
  (function() {
295
- var dialogs = document.querySelectorAll('.ui-dialog');
296
- for (var i = 0; i < dialogs.length; i++) {
297
- var dialog = dialogs[i];
298
- if (dialog.style.display === 'none') continue;
299
- // Make sure it's not the legends dialog
300
- var isLegends = !!dialog.querySelector('[id*="legends_content"]');
301
- if (!isLegends) return true;
302
- }
303
- return false;
133
+ var panel = document.querySelector('[id*="reservationPanel"]');
134
+ var saveBtn = document.querySelector('.btn-save');
135
+ var fromTime = document.querySelector('select[name*="fromTime_input"]');
136
+ return {
137
+ hasPanel: !!panel,
138
+ panelVisible: panel ? panel.offsetWidth > 0 : false,
139
+ hasSaveBtn: !!saveBtn,
140
+ saveBtnVisible: saveBtn ? saveBtn.offsetWidth > 0 : false,
141
+ saveBtnId: saveBtn ? saveBtn.id : null,
142
+ fromTime: fromTime ? fromTime.value : null
143
+ };
304
144
  })()
305
145
  JS
146
+ log "Reservation panel: #{panel_state.inspect}"
306
147
 
307
- unless dialog_visible
308
- log 'ERROR: Booking dialog did not open!'
148
+ unless panel_state['saveBtnVisible']
149
+ log 'Save button not visible after slot click'
309
150
  if @debug
310
- @browser.screenshot(path: '/tmp/no_dialog.png')
311
- log 'Saved screenshot to /tmp/no_dialog.png'
151
+ @browser.screenshot(path: '/tmp/no_save_btn.png', full: true)
152
+ log 'Saved screenshot to /tmp/no_save_btn.png'
312
153
  end
313
- raise BookingError, 'Booking dialog did not open after clicking slot'
314
- end
315
-
316
- log 'Booking dialog opened'
317
-
318
- # Capture dialog HTML for debugging
319
- if @debug
320
- dialog_html = @browser.evaluate(<<~JS)
321
- (function() {
322
- var dialogs = document.querySelectorAll('.ui-dialog');
323
- for (var i = 0; i < dialogs.length; i++) {
324
- var dialog = dialogs[i];
325
- var style = window.getComputedStyle(dialog);
326
- if (style.display !== 'none' && style.visibility !== 'hidden') {
327
- return dialog.outerHTML;
328
- }
329
- }
330
- return null;
331
- })()
332
- JS
333
-
334
- if dialog_html
335
- File.write('/tmp/booking_dialog.html', dialog_html)
336
- log 'Saved booking dialog HTML to /tmp/booking_dialog.html'
337
- end
338
-
339
- @browser.screenshot(path: '/tmp/booking_dialog.png')
340
- log 'Saved screenshot to /tmp/booking_dialog.png'
154
+ raise BookingError, 'Reservation panel did not appear after clicking slot'
341
155
  end
342
156
 
343
157
  if dry_run
344
- log 'Dry run - closing dialog without booking'
345
- close_dialog
158
+ log 'Dry run - not clicking Save'
346
159
  return {
347
160
  success: true,
348
161
  dry_run: true,
349
162
  court: slot_info[:court],
350
- time: time,
351
- message: 'Dry run completed - booking dialog opened successfully'
163
+ time: slot_info[:time],
164
+ message: "Dry run - reservation panel opened for #{slot_info[:court]} at #{slot_info[:time]}"
352
165
  }
353
166
  end
354
167
 
355
- # Try to find and click the save button in the browser
356
- log 'Looking for save button in dialog...'
357
- sleep 1 # Quick check before dialog might close
358
-
359
- # Check for save button immediately
360
- quick_check = @browser.evaluate(<<~JS)
168
+ # Click the Save button
169
+ log 'Clicking Save button...'
170
+ save_btn_id = panel_state['saveBtnId']
171
+ @browser.evaluate(<<~JS)
361
172
  (function() {
362
- var dialogs = document.querySelectorAll('.ui-dialog');
363
- var visibleCount = 0;
364
- var saveButton = null;
365
-
366
- for (var i = 0; i < dialogs.length; i++) {
367
- var dialog = dialogs[i];
368
- var style = window.getComputedStyle(dialog);
369
- if (style.display === 'none' || style.visibility === 'hidden') continue;
370
-
371
- visibleCount++;
372
-
373
- // Try to find save button
374
- var btn =
375
- dialog.querySelector('a.btn-save') ||
376
- dialog.querySelector('button.btn-save') ||
377
- dialog.querySelector('a[id*="save"]') ||
378
- dialog.querySelector('button[id*="save"]') ||
379
- dialog.querySelector('a.ui-commandlink:not(.cross)') ||
380
- dialog.querySelector('.ui-button:not(.ui-dialog-titlebar-close):not(.cross)') ||
381
- dialog.querySelector('a.ui-area-btn-success') ||
382
- dialog.querySelector('button[type="submit"]');
383
-
384
- if (btn) {
385
- saveButton = {
386
- id: btn.id || '',
387
- text: btn.textContent.trim(),
388
- className: btn.className || ''
389
- };
390
- btn.click();
391
- return { found: true, clicked: true, button: saveButton, visibleCount: visibleCount };
392
- }
393
- }
394
-
395
- return { found: false, clicked: false, visibleCount: visibleCount };
173
+ var btn = document.getElementById('#{save_btn_id}');
174
+ if (btn) btn.click();
396
175
  })()
397
176
  JS
398
177
 
399
- log "Quick check result: #{quick_check.inspect}"
400
-
401
- if quick_check['clicked']
402
- log "Save button clicked immediately!"
403
- sleep 3
404
- page_text = @browser.evaluate('document.body.textContent')
405
- if page_text =~ /confirmed|success|booked/i
406
- log 'Booking confirmed'
407
- return {
408
- success: true,
409
- court: slot_info[:court],
410
- time: "#{slot_info[:time]} - #{(Time.parse(slot_info[:time]) + 3600).strftime('%-I:%M %p').upcase}",
411
- confirmation: 'Booking confirmed'
412
- }
413
- end
414
- end
415
-
416
- sleep 2 # Wait a bit more
178
+ # Wait for save AJAX to complete
179
+ wait_for_ajax(timeout: 10)
180
+ sleep 2
417
181
 
418
- # Get dialog info (any visible dialog)
419
- dialog_info = @browser.evaluate(<<~JS)
182
+ # Check for success or error
183
+ result_state = @browser.evaluate(<<~JS)
420
184
  (function() {
421
- var dialogs = document.querySelectorAll('.ui-dialog');
422
- var visibleDialogs = [];
423
-
424
- // Find all visible dialogs
425
- for (var i = 0; i < dialogs.length; i++) {
426
- var dialog = dialogs[i];
427
- var style = window.getComputedStyle(dialog);
428
- if (style.display !== 'none' && style.visibility !== 'hidden') {
429
- visibleDialogs.push(dialog);
430
- }
185
+ var body = document.body.textContent;
186
+
187
+ // Check for PrimeFaces error messages
188
+ var msgs = document.querySelectorAll('.ui-messages-error, .ui-message-error');
189
+ var errors = [];
190
+ for (var i = 0; i < msgs.length; i++) {
191
+ var txt = msgs[i].textContent.trim();
192
+ if (txt) errors.push(txt);
431
193
  }
432
194
 
433
- if (visibleDialogs.length === 0) return { found: false, count: 0 };
434
-
435
- // Use the last visible dialog (most recently opened)
436
- var dialog = visibleDialogs[visibleDialogs.length - 1];
437
-
438
- // Find all buttons in this dialog
439
- var buttons = [];
440
- var allBtns = dialog.querySelectorAll('a, button');
441
- for (var j = 0; j < allBtns.length; j++) {
442
- var btn = allBtns[j];
443
- buttons.push({
444
- tag: btn.tagName,
445
- id: btn.id || '',
446
- className: btn.className || '',
447
- text: btn.textContent.trim(),
448
- disabled: btn.disabled || btn.className.indexOf('disabled') > -1
449
- });
195
+ // Check PrimeFaces Growl notifications (success/error popups)
196
+ var growlMsgs = document.querySelectorAll('.ui-growl-message, .ui-growl-item');
197
+ var growls = [];
198
+ for (var i = 0; i < growlMsgs.length; i++) {
199
+ var summary = growlMsgs[i].querySelector('.ui-growl-title');
200
+ var detail = growlMsgs[i].querySelector('.ui-growl-message');
201
+ var text = (summary ? summary.textContent : '') + ' ' + (detail ? detail.textContent : '');
202
+ if (!text.trim()) text = growlMsgs[i].textContent.trim();
203
+ if (text.trim()) growls.push(text.trim());
450
204
  }
451
205
 
452
- var content = dialog.querySelector('.ui-dialog-content');
206
+ // Check if reservation panel is gone (success) or still showing (error)
207
+ var panel = document.querySelector('[id*="reservationPanel"]');
208
+ var panelVisible = panel ? panel.offsetWidth > 0 : false;
209
+ var saveBtn = document.querySelector('.btn-save');
210
+ var saveBtnVisible = saveBtn ? saveBtn.offsetWidth > 0 : false;
211
+
212
+ // Check for Back button (visible means we're still on the reservation form)
213
+ var backBtn = document.querySelector('.btn-back');
214
+ var backBtnVisible = backBtn ? backBtn.offsetWidth > 0 : false;
215
+
216
+ var bodyLower = body.toLowerCase();
453
217
  return {
454
- found: true,
455
- count: visibleDialogs.length,
456
- contentId: content ? content.id : '',
457
- buttons: buttons,
458
- html: dialog.innerHTML.substring(0, 1000)
218
+ errors: errors,
219
+ growls: growls,
220
+ panelStillVisible: panelVisible,
221
+ saveBtnStillVisible: saveBtnVisible,
222
+ backBtnStillVisible: backBtnVisible,
223
+ hasConfirmation: bodyLower.indexOf('confirmed') > -1 ||
224
+ bodyLower.indexOf('success') > -1 ||
225
+ bodyLower.indexOf('booked') > -1 ||
226
+ bodyLower.indexOf('reservation has been') > -1
459
227
  };
460
228
  })()
461
229
  JS
230
+ log "After save: #{result_state.inspect}"
462
231
 
463
- log "Dialog info: #{dialog_info.inspect}"
464
-
465
- # Try multiple save button selectors (use last visible dialog)
466
- save_result = @browser.evaluate(<<~JS)
467
- (function() {
468
- // Find all visible dialogs
469
- var dialogs = document.querySelectorAll('.ui-dialog');
470
- var visibleDialogs = [];
471
- for (var i = 0; i < dialogs.length; i++) {
472
- var dialog = dialogs[i];
473
- var style = window.getComputedStyle(dialog);
474
- if (style.display !== 'none' && style.visibility !== 'hidden') {
475
- visibleDialogs.push(dialog);
476
- }
477
- }
232
+ if @debug
233
+ @browser.screenshot(path: '/tmp/after_save.png', full: true)
234
+ log 'Saved screenshot to /tmp/after_save.png'
235
+ end
478
236
 
479
- if (visibleDialogs.length === 0) return { clicked: false, reason: 'no visible dialogs' };
480
-
481
- // Use the last visible dialog (most recently opened)
482
- var targetDialog = visibleDialogs[visibleDialogs.length - 1];
483
-
484
- // Try to find save/submit button in this dialog
485
- var saveBtn =
486
- targetDialog.querySelector('a.btn-save') ||
487
- targetDialog.querySelector('button.btn-save') ||
488
- targetDialog.querySelector('a[id*="save"]') ||
489
- targetDialog.querySelector('button[id*="save"]') ||
490
- targetDialog.querySelector('a.ui-commandlink:not(.cross)') ||
491
- targetDialog.querySelector('.ui-button:not(.ui-dialog-titlebar-close):not(.cross)') ||
492
- targetDialog.querySelector('a.ui-area-btn-success') ||
493
- targetDialog.querySelector('button[type="submit"]');
494
-
495
- if (saveBtn) {
496
- var btnInfo = {
497
- id: saveBtn.id || '',
498
- className: saveBtn.className || '',
499
- text: saveBtn.textContent.trim(),
500
- disabled: saveBtn.disabled || saveBtn.className.indexOf('disabled') > -1
501
- };
502
- console.log('Found save button:', saveBtn.id || saveBtn.className);
503
- saveBtn.click();
504
- return { clicked: true, button: btnInfo };
505
- }
506
- return { clicked: false, reason: 'no save button found', dialogCount: visibleDialogs.length };
507
- })()
508
- JS
237
+ if result_state['errors'].any?
238
+ raise BookingError, "Booking failed: #{result_state['errors'].join(', ')}"
239
+ end
509
240
 
510
- save_clicked = save_result['clicked']
511
- log "Save button result: #{save_result.inspect}" if save_result['button']
241
+ # Check growl messages for errors
242
+ growl_errors = result_state['growls'].select { |g| g =~ /error|fail|unable|invalid/i }
243
+ if growl_errors.any?
244
+ raise BookingError, "Booking failed: #{growl_errors.join(', ')}"
245
+ end
512
246
 
513
- if save_clicked
514
- log 'Save button clicked, waiting for confirmation...'
515
- sleep 3
247
+ # Success indicators:
248
+ # 1. Save button disappeared (panel closed after successful save)
249
+ # 2. Confirmation text in body or growl
250
+ # 3. Back button gone (returned to slot view)
251
+ growl_success = result_state['growls'].any? { |g| g =~ /success|confirm|booked|reserved/i }
516
252
 
517
- # Check for success message
518
- page_text = @browser.evaluate('document.body.textContent')
519
- if page_text =~ /confirmed|success|booked/i
520
- log 'Booking confirmed'
521
- return {
522
- success: true,
523
- court: slot_info[:court],
524
- time: "#{slot_info[:time]} - #{(Time.parse(slot_info[:time]) + 3600).strftime('%-I:%M %p').upcase}",
525
- confirmation: 'Booking confirmed'
526
- }
527
- else
528
- log 'Warning: Save button clicked but no confirmation message detected'
529
- return {
530
- success: false,
531
- error: 'Booking uncertain - please check My Reservations'
532
- }
533
- end
253
+ if !result_state['saveBtnStillVisible'] || result_state['hasConfirmation'] || growl_success
254
+ confirmation_msg = result_state['growls'].first || 'Booking confirmed'
255
+ log "Booking confirmed: #{confirmation_msg}"
256
+ {
257
+ success: true,
258
+ court: slot_info[:court],
259
+ time: slot_info[:time],
260
+ confirmation: confirmation_msg
261
+ }
534
262
  else
535
- log 'No save button found, falling back to HTTP client approach'
536
-
537
- # Fallback to HTTP client approach
538
- http_client = Client.new(debug: @debug)
539
-
540
- # Transfer cookies from browser to HTTP client
541
- browser_cookies = @browser.cookies.all
542
- browser_cookies.each do |name, cookie|
543
- http_client.cookies[name] = cookie.value
544
- end
545
-
546
- # Calculate end time (assuming 1-hour slots)
547
- start_time_obj = Time.parse(slot_info[:time])
548
- end_time = (start_time_obj + 3600).strftime('%-I:%M %p').upcase
549
-
550
- # Pass the slot info directly to avoid re-finding
551
- result = http_client.book(
552
- time,
553
- date: date,
554
- court: court,
555
- activity: activity,
556
- dry_run: false,
557
- slot_info: {
558
- area_id: slot_info[:area_id],
559
- court: slot_info[:court],
560
- start_time: slot_info[:time],
561
- end_time: end_time
562
- }
563
- )
564
-
565
- result
263
+ log 'Booking status uncertain - check My Reservations'
264
+ {
265
+ success: false,
266
+ error: 'Booking uncertain - please check My Reservations'
267
+ }
566
268
  end
567
269
  ensure
568
270
  quit
@@ -646,46 +348,173 @@ module Truenorth
646
348
  @browser.go_to("#{@base_url}/group/pages/facility-booking")
647
349
  sleep 3
648
350
 
649
- # Select activity
650
351
  activity_id = Client::ACTIVITIES[activity.to_s.downcase] || '5'
651
- select_activity(activity_id)
652
- sleep 5
653
352
 
654
- # TODO: Navigate to specific date if not today
655
- current_url = @browser.url
656
- page_title = @browser.title
353
+ # Step 1: Change activity via PrimeFaces SelectOneMenu widget (triggers AJAX)
354
+ # This updates the server-side activity. Do NOT reload - that resets to default (golf).
355
+ select_activity_ui(activity_id)
356
+ wait_for_ajax
357
+
358
+ # Step 2: Navigate to correct date - the dateSelect AJAX renders the table
359
+ # with the correct activity from the session updated in step 1
360
+ navigate_to_date(date, force: true)
361
+ wait_for_ajax
362
+
363
+ # No extra sheetDate fix needed - the Calendar widget properly updates the server
364
+
365
+ # Verify correct activity courts are shown
366
+ verify = @browser.evaluate(<<~JS)
367
+ (function() {
368
+ var slots = document.querySelectorAll('td.slot div[data-area-id]');
369
+ var areaIds = {};
370
+ for (var i = 0; i < slots.length; i++) {
371
+ var aid = slots[i].getAttribute('data-area-id');
372
+ areaIds[aid] = (areaIds[aid] || 0) + 1;
373
+ }
374
+ return { totalSlots: slots.length, areaIds: areaIds };
375
+ })()
376
+ JS
377
+ log "After navigation - slots: #{verify['totalSlots']}, areas: #{verify['areaIds']}"
378
+
657
379
  log "Navigated to #{activity} for #{date}"
658
- log "Current URL: #{current_url}"
659
- log "Page title: #{page_title}"
660
380
  end
661
381
 
662
- def select_activity(activity_id)
382
+ def wait_for_ajax(timeout: 15)
383
+ timeout.times do |i|
384
+ sleep 1
385
+ idle = @browser.evaluate(<<~JS)
386
+ (function() {
387
+ try {
388
+ if (typeof PrimeFaces !== 'undefined' && PrimeFaces.ajax) {
389
+ if (PrimeFaces.ajax.Queue && PrimeFaces.ajax.Queue.isEmpty) {
390
+ return PrimeFaces.ajax.Queue.isEmpty();
391
+ }
392
+ // Older PrimeFaces versions
393
+ if (PrimeFaces.ajax.QUEUE && PrimeFaces.ajax.QUEUE.isEmpty) {
394
+ return PrimeFaces.ajax.QUEUE.isEmpty();
395
+ }
396
+ }
397
+ return true;
398
+ } catch(e) { return true; }
399
+ })()
400
+ JS
401
+ if idle
402
+ log "AJAX completed after #{i + 1} seconds" if @debug
403
+ break
404
+ end
405
+ end
406
+ sleep 1 # Extra buffer for DOM rendering
407
+ end
408
+
409
+ def select_activity_ui(activity_id)
663
410
  log "Selecting activity ID: #{activity_id}"
664
- @browser.execute(<<~JS)
411
+
412
+ # Use PrimeFaces SelectOneMenu widget's selectItem method (non-silent)
413
+ # This fires the change behavior AJAX, properly updating the server session
414
+ result = @browser.evaluate(<<~JS)
415
+ (function() {
416
+ for (var key in PrimeFaces.widgets) {
417
+ var w = PrimeFaces.widgets[key];
418
+ if (!w.input || !w.input[0] || w.input[0].tagName !== 'SELECT') continue;
419
+ var sel = w.input[0];
420
+
421
+ var targetIdx = -1;
422
+ for (var i = 0; i < sel.options.length; i++) {
423
+ if (sel.options[i].value === '#{activity_id}') {
424
+ targetIdx = i;
425
+ break;
426
+ }
427
+ }
428
+ if (targetIdx === -1) continue;
429
+
430
+ var oldValue = sel.value;
431
+ if (oldValue === '#{activity_id}') {
432
+ return { success: true, method: 'already_selected', value: oldValue };
433
+ }
434
+
435
+ // selectItem without silent flag triggers the change behavior AJAX
436
+ w.selectItem(w.items.eq(targetIdx));
437
+
438
+ return {
439
+ success: true, method: 'ui_click',
440
+ oldValue: oldValue, newValue: sel.value,
441
+ targetIdx: targetIdx, widgetKey: key
442
+ };
443
+ }
444
+ return { error: 'widget not found' };
445
+ })()
446
+ JS
447
+ log "Activity select result: #{result.inspect}"
448
+ end
449
+
450
+ def navigate_to_date(date, force: false)
451
+ target_str = date.strftime('%m/%d/%Y')
452
+
453
+ current_date = @browser.evaluate(<<~JS)
454
+ (function() {
455
+ var input = document.querySelector('input[name*="sheetDate"]');
456
+ return input ? input.value : null;
457
+ })()
458
+ JS
459
+
460
+ if current_date == target_str && !force
461
+ log "Already on correct date: #{target_str}"
462
+ return
463
+ end
464
+
465
+ log "Navigating from #{current_date} to #{target_str}"
466
+
467
+ # Use the Calendar widget (j_idt79) which has setDate + dateSelect behavior.
468
+ # This properly updates the server's session date, unlike directly setting
469
+ # the sheetDate input which only updates the display.
470
+ result = @browser.evaluate(<<~JS)
665
471
  (function() {
666
- var select = document.querySelector('select[id*="j_idt51_input"]');
667
- if (select) {
668
- select.value = '#{activity_id}';
669
- PrimeFaces.ab({
670
- s: "_activities_WAR_northstarportlet_:activityForm:j_idt51",
671
- e: "change",
672
- f: "_activities_WAR_northstarportlet_:activityForm",
673
- p: "_activities_WAR_northstarportlet_:activityForm:j_idt51",
674
- u: "_activities_WAR_northstarportlet_:activityForm"
472
+ var parts = '#{target_str}'.split('/');
473
+ var targetDate = new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
474
+
475
+ // Find the Calendar widget with setDate + dateSelect behavior
476
+ for (var key in PrimeFaces.widgets) {
477
+ var w = PrimeFaces.widgets[key];
478
+ if (typeof w.setDate !== 'function') continue;
479
+ if (!w.cfg || !w.cfg.behaviors || !w.cfg.behaviors.dateSelect) continue;
480
+
481
+ w.setDate(targetDate);
482
+ w.cfg.behaviors.dateSelect.call(w, {
483
+ params: [{ name: w.id + '_selectedDate', value: targetDate.getTime() }]
675
484
  });
485
+ return { success: true, method: 'setDate+behavior', widgetKey: key };
676
486
  }
487
+
488
+ // Fallback: click the day tab link if target is within the visible week
489
+ var dayLinks = document.querySelectorAll('a[id*="j_idt98"]');
490
+ for (var i = 0; i < dayLinks.length; i++) {
491
+ var dayNum = dayLinks[i].textContent.match(/\\b(\\d{1,2})\\b/);
492
+ if (dayNum && parseInt(dayNum[1]) === parseInt(parts[1])) {
493
+ dayLinks[i].click();
494
+ return { success: true, method: 'dayTab_click', day: dayNum[1] };
495
+ }
496
+ }
497
+
498
+ return { error: 'No Calendar widget or matching day tab found' };
677
499
  })()
678
500
  JS
501
+ log "Date navigation result: #{result.inspect}"
679
502
  end
680
503
 
681
- def find_and_click_slot(target_time, preferred_court)
504
+ def find_and_click_slot(target_time, preferred_court, booking_date = Date.today)
682
505
  # Normalize time
683
506
  normalized_time = target_time.strip.gsub(/^0/, '').upcase
684
507
 
685
- # Find all open slots matching the time
508
+ # Only filter past-time slots for today/past dates.
509
+ # The page erroneously applies past-time class to future dates based on current time of day,
510
+ # but the server accepts bookings for those slots fine.
511
+ is_today = booking_date <= Date.today
512
+ slot_selector = is_today ? 'td.slot.open:not(.past-time)' : 'td.slot.open'
513
+
514
+ # Find all open slots
686
515
  slots = @browser.evaluate(<<~JS)
687
516
  (function() {
688
- var openSlots = document.querySelectorAll('td.slot.open');
517
+ var openSlots = document.querySelectorAll('#{slot_selector}');
689
518
  var result = [];
690
519
  for (var i = 0; i < openSlots.length; i++) {
691
520
  var td = openSlots[i];
@@ -694,10 +523,12 @@ module Truenorth
694
523
  var div = td.querySelector('div[data-start-time]');
695
524
  var areaId = div ? div.getAttribute('data-area-id') : null;
696
525
 
697
- if (time && areaId) {
526
+ if (areaId) {
698
527
  result.push({
699
528
  time: time,
700
- areaId: areaId
529
+ areaId: areaId,
530
+ startTime: div ? div.getAttribute('data-start-time') : '',
531
+ endTime: div ? div.getAttribute('data-end-time') : ''
701
532
  });
702
533
  }
703
534
  }
@@ -705,9 +536,15 @@ module Truenorth
705
536
  })()
706
537
  JS
707
538
 
708
- # Find matching slot
539
+ # Debug: show available times
540
+ unique_times = slots.map { |s| s['startTime'] }.uniq.sort
541
+ log "Available times (#{slots.length} bookable slots): #{unique_times.first(10).join(', ')}#{unique_times.length > 10 ? '...' : ''}"
542
+ log "Looking for: '#{normalized_time}'"
543
+
544
+ # Find matching slot by time display or data-start-time
709
545
  matching_slot = slots.find do |slot|
710
- time_match = slot['time'].gsub(/^0/, '').upcase == normalized_time
546
+ time_match = slot['time'].gsub(/^0/, '').upcase == normalized_time ||
547
+ slot['startTime'].gsub(/^0/, '').upcase == normalized_time
711
548
  court_match = if preferred_court
712
549
  court_name = Client::COURTS[slot['areaId']]
713
550
  court_name&.downcase&.include?(preferred_court.downcase)
@@ -719,73 +556,34 @@ module Truenorth
719
556
 
720
557
  return nil unless matching_slot
721
558
 
722
- # Click the slot
723
559
  court_name = Client::COURTS[matching_slot['areaId']]
724
- log "Clicking slot: #{court_name} at #{matching_slot['time']}"
560
+ log "Clicking slot: #{court_name} at #{matching_slot['startTime']}"
725
561
 
726
- # Find and click using the area ID
727
- @browser.execute(<<~JS, matching_slot['areaId'])
562
+ # Call rc_showReservationScreen directly with slot parameters.
563
+ # This bypasses the client-side past-time check (which erroneously blocks
564
+ # future-date slots) and fires the PrimeFaces AJAX to open the reservation panel.
565
+ click_result = @browser.evaluate(<<~JS)
728
566
  (function() {
729
- var div = document.querySelector('div[data-area-id="' + arguments[0] + '"]');
730
- if (div) {
731
- var td = div.parentElement;
732
- while (td && td.tagName !== 'TD') {
733
- td = td.parentElement;
734
- }
735
- if (td) td.click();
736
- }
567
+ rc_showReservationScreen([
568
+ {name: 'activityAreaId', value: '#{matching_slot['areaId']}'},
569
+ {name: 'startTime', value: '#{matching_slot['startTime']}'},
570
+ {name: 'endTime', value: '#{matching_slot['endTime']}'}
571
+ ]);
572
+ return { success: true, areaId: '#{matching_slot['areaId']}', startTime: '#{matching_slot['startTime']}' };
737
573
  })()
738
574
  JS
575
+ log "Slot click result: #{click_result.inspect}"
576
+
577
+ wait_for_ajax
578
+ sleep 1
739
579
 
740
580
  {
741
581
  court: court_name,
742
- time: matching_slot['time'],
582
+ time: matching_slot['startTime'],
743
583
  area_id: matching_slot['areaId']
744
584
  }
745
585
  end
746
586
 
747
- def close_dialog
748
- @browser.execute(<<~JS)
749
- (function() {
750
- var closeBtn = document.querySelector('.ui-dialog-titlebar-close');
751
- if (closeBtn) closeBtn.click();
752
- })()
753
- JS
754
- end
755
-
756
- def submit_booking
757
- log 'Submitting booking...'
758
-
759
- # Try to find and trigger the save button via PrimeFaces
760
- success = @browser.execute(<<~JS)
761
- (function() {
762
- // Look for the save button anywhere on the page
763
- var saveBtn = document.querySelector('a.btn-save, button.btn-save') ||
764
- document.querySelector('a[id*="save"]') ||
765
- document.querySelector('button[id*="save"]') ||
766
- document.querySelector('.ui-dialog a.ui-commandlink') ||
767
- document.querySelector('.ui-dialog button[type="button"]');
768
-
769
- if (saveBtn) {
770
- console.log('Clicking save button:', saveBtn.id);
771
- saveBtn.click();
772
- return true;
773
- }
774
-
775
- // If no button found, try submitting via form
776
- var form = document.querySelector('form[id*="activityForm"]');
777
- if (form) {
778
- console.log('Submitting form');
779
- form.submit();
780
- return true;
781
- }
782
-
783
- return false;
784
- })()
785
- JS
786
-
787
- log "Booking submission triggered: #{success}"
788
- end
789
587
 
790
588
  def parse_reservations_table
791
589
  reservations = []
data/lib/truenorth/cli.rb CHANGED
@@ -117,9 +117,10 @@ module Truenorth
117
117
  rescue Error => e
118
118
  say "Error: #{e.message}", :red
119
119
 
120
- # If no slot available, show nearby available times
120
+ # If no slot available, show nearby available times using HTTP client
121
121
  if e.message.include?('No slot available')
122
- show_nearby_availability(client, date, time, activity)
122
+ http_client = Client.new(debug: options[:debug])
123
+ show_nearby_availability(http_client, date, time, activity)
123
124
  end
124
125
 
125
126
  exit 1
@@ -28,23 +28,22 @@ module Truenorth
28
28
  'meeting' => '8'
29
29
  }.freeze
30
30
 
31
- # Court area IDs
31
+ # Court area IDs (from page column headers)
32
32
  COURTS = {
33
- '16' => 'Squash Court 1',
34
- '17' => 'Squash Court 2',
35
- '18' => 'Squash Court 3',
36
- '30' => 'Court 1',
37
- '31' => 'Court 2',
38
- '32' => 'Court 3'
33
+ '16' => 'Golf Simulator 1',
34
+ '17' => 'Golf Simulator 2',
35
+ '30' => 'Squash Court 1',
36
+ '31' => 'Squash Court 2',
37
+ '32' => 'Squash Court 3'
39
38
  }.freeze
40
39
 
41
40
  # Court IDs by activity
42
41
  COURT_IDS_BY_ACTIVITY = {
43
- 'squash' => %w[16 17 18],
44
- 'golf' => %w[30 31 32],
45
- 'music' => %w[16 17 18], # May need to update these
46
- 'room' => %w[30 31 32], # May need to update these
47
- 'meeting' => %w[30 31 32] # May need to update these
42
+ 'squash' => %w[30 31 32],
43
+ 'golf' => %w[16 17],
44
+ 'music' => %w[16 17], # May need to update these
45
+ 'room' => %w[30 31 32], # May need to update these
46
+ 'meeting' => %w[30 31 32]
48
47
  }.freeze
49
48
 
50
49
  attr_reader :cookies, :debug_log, :logged_in
@@ -56,7 +55,8 @@ module Truenorth
56
55
  @cookies = Config.cookies || {}
57
56
  @debug = debug
58
57
  @debug_log = StringIO.new
59
- @logged_in = !@cookies.empty? # If we have cookies, assume logged in
58
+ @logged_in = !@cookies.empty? # If we have cookies, will verify on first use
59
+ @last_verified_response = nil
60
60
 
61
61
  log "Loaded #{@cookies.length} cookies from cache" if @logged_in && @debug
62
62
  end
@@ -286,7 +286,9 @@ module Truenorth
286
286
 
287
287
  log "\n=== GET RESERVATIONS ==="
288
288
 
289
- response = get(RESERVATIONS_PATH)
289
+ # Reuse the response from session verification if available
290
+ response = @last_verified_response || get(RESERVATIONS_PATH)
291
+ @last_verified_response = nil
290
292
  html = Nokogiri::HTML(response.body)
291
293
 
292
294
  reservations = []
@@ -478,11 +480,31 @@ module Truenorth
478
480
  private
479
481
 
480
482
  def ensure_logged_in!
481
- return if @logged_in
483
+ if @logged_in
484
+ # Verify cached session is still valid with a lightweight check
485
+ response = get(RESERVATIONS_PATH)
486
+ if authenticated_response?(response)
487
+ @last_verified_response = response
488
+ return
489
+ end
490
+
491
+ # Session expired - clear stale state and re-login
492
+ log 'Session expired, re-authenticating...'
493
+ @logged_in = false
494
+ @cookies = {}
495
+ end
482
496
 
483
497
  login
484
498
  end
485
499
 
500
+ # Check if a response is from an authenticated session (not a login page)
501
+ def authenticated_response?(response)
502
+ return false unless response.is_a?(Net::HTTPSuccess)
503
+
504
+ body = response.body
505
+ !body.include?('LoginPortlet') && body.include?('Sign Out')
506
+ end
507
+
486
508
  def parse_slots(html)
487
509
  slots = {}
488
510
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Truenorth
4
- VERSION = '0.5.1'
4
+ VERSION = '0.6.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: truenorth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - usiegj00
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-07 00:00:00.000000000 Z
11
+ date: 2026-02-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64