diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000000..cabf43b5dd
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24
\ No newline at end of file
diff --git a/app/Http/Controllers/Site/BookingsPagesController.php b/app/Http/Controllers/Site/BookingsPagesController.php
new file mode 100644
index 0000000000..d92198db8e
--- /dev/null
+++ b/app/Http/Controllers/Site/BookingsPagesController.php
@@ -0,0 +1,72 @@
+bookingRepo = $bookingRepo;
+ }
+
+ public function show($id)
+ {
+ $booking = Booking::findOrFail($id);
+
+ return response()->json([
+ 'id' => $booking->id,
+ 'position' => $booking->position,
+ 'controller_name' => $booking->member->name ?? 'Unknown',
+ 'from' => $booking->from,
+ 'to' => $booking->to,
+ ]);
+ }
+
+ /**
+ * Display the bookings calendar for a given month and year.
+ *
+ * @param int|null $year
+ * @param int|null $month
+ * @return \Illuminate\View\View
+ */
+ public function index($year = null, $month = null)
+ {
+ $date = Carbon::createFromDate($year ?? now()->year, $month ?? now()->month, 1)->startOfMonth();
+
+ $start = $date->copy()->startOfWeek();
+ $end = $date->copy()->endOfMonth()->endOfWeek();
+
+ $calendar = [];
+ $current = $start->copy();
+
+ // Fetch Bookings for the entire Calendar Range
+ $bookings = $this->bookingRepo->getBookingsBetween($start, $end);
+
+ while ($current <= $end) {
+ $week = [];
+ for ($i = 0; $i < 7; $i++) {
+ $dayBookings = $bookings->filter(function ($booking) use ($current) {
+ return $booking->date->isSameDay($current);
+ });
+
+ $week[] = [
+ 'date' => $current->copy(),
+ 'bookings' => $dayBookings,
+ ];
+ $current->addDay();
+ }
+ $calendar[] = $week;
+ }
+
+ $prevMonth = $date->copy()->subMonth();
+ $nextMonth = $date->copy()->addMonth();
+
+ return view('site.bookings.index', compact('calendar', 'date', 'prevMonth', 'nextMonth'));
+ }
+}
diff --git a/app/Libraries/Bookings.php b/app/Libraries/Bookings.php
new file mode 100644
index 0000000000..1088db41e7
--- /dev/null
+++ b/app/Libraries/Bookings.php
@@ -0,0 +1,31 @@
+copy()->setTimezone('UTC');
+ $endUtc = Carbon::parse($bookingEnd, 'UTC')->setTimezone('UTC');
+
+ // 1️⃣ If the booking date is before today (UTC), it’s past
+ if ($dayDate->lt($nowUtc->copy()->startOfDay())) {
+ return true;
+ }
+
+ // 2️⃣ If the booking is today and has already ended, it’s past
+ if ($dayDate->isSameDay($nowUtc)) {
+ return $endUtc->lessThanOrEqualTo($nowUtc);
+ }
+
+ // 3️⃣ Otherwise it’s upcoming
+ return false;
+ }
+}
diff --git a/app/Models/Cts/Booking.php b/app/Models/Cts/Booking.php
index 39178f22b6..dadbf8f5ca 100644
--- a/app/Models/Cts/Booking.php
+++ b/app/Models/Cts/Booking.php
@@ -64,4 +64,19 @@ public function isMentoring()
{
return $this->type == 'ME';
}
+
+ public function isSeminar()
+ {
+ return $this->type == 'GS';
+ }
+
+ public function session()
+ {
+ return $this->belongsTo(\App\Models\Cts\Session::class, 'type_id', 'id');
+ }
+
+ public function exams()
+ {
+ return $this->belongsTo(\App\Models\Cts\Exams::class, 'type_id', 'id');
+ }
}
diff --git a/app/Models/Cts/Exams.php b/app/Models/Cts/Exams.php
new file mode 100644
index 0000000000..a4cb57d68c
--- /dev/null
+++ b/app/Models/Cts/Exams.php
@@ -0,0 +1,24 @@
+belongsTo(Member::class, 'exmr_id', 'id');
+ }
+}
diff --git a/app/Repositories/Cts/BookingRepository.php b/app/Repositories/Cts/BookingRepository.php
index d56400a386..c2ecb927a3 100644
--- a/app/Repositories/Cts/BookingRepository.php
+++ b/app/Repositories/Cts/BookingRepository.php
@@ -8,10 +8,19 @@
class BookingRepository
{
+ public function getBookingsBetween($startDate, $endDate)
+ {
+ return Booking::with(['member', 'session.mentor'])
+ ->whereBetween('date', [$startDate->toDateString(), $endDate->toDateString()])
+ ->get()->each(function ($booking) {
+ $booking->date = Carbon::parse($booking->date);
+ });
+ }
+
public function getBookings(Carbon $date)
{
$bookings = Booking::where('date', '=', $date->toDateString())
- ->with('member')
+ ->with(['member', 'session.mentor'])
->orderBy('from')
->get();
@@ -21,7 +30,7 @@ public function getBookings(Carbon $date)
public function getTodaysBookings()
{
$bookings = Booking::where('date', '=', Carbon::now()->toDateString())
- ->with('member')
+ ->with(['member', 'session.mentor'])
->orderBy('from')
->get();
@@ -32,7 +41,7 @@ public function getTodaysLiveAtcBookings()
{
$bookings = Booking::where('date', '=', Carbon::now()->toDateString())
->networkAtc()
- ->with('member')
+ ->with(['member', 'session.mentor'])
->orderBy('from')
->get();
@@ -44,7 +53,7 @@ public function getTodaysLiveAtcBookingsWithoutEvents()
$bookings = Booking::where('date', '=', Carbon::now()->toDateString())
->notEvent()
->networkAtc()
- ->with('member')
+ ->with(['member', 'session.mentor'])
->orderBy('from')
->get();
@@ -60,6 +69,50 @@ private function formatBookings(Collection $bookings)
$booking->member = $this->formatMember($booking);
$booking->unsetRelation('member');
+ if ($booking->type === 'ME' && $booking->session) {
+ $mentorName = 'Unknown';
+
+ // Safely get mentor name and ID
+ if ($booking->session->mentor) {
+ $mentorName = $booking->session->mentor->name.' ('.$booking->session->mentor->cid.')';
+ }
+
+ $booking->session_details = [
+ 'id' => $booking->session->id,
+ 'position' => $booking->session->position,
+ 'student_id' => $booking->session->student_id,
+ 'mentor_id' => $booking->session->mentor_id,
+ 'mentor' => $mentorName,
+ 'date' => $booking->session->date_1,
+ 'from' => $booking->session->from_1,
+ 'to' => $booking->session->to_1,
+ 'request_time' => $booking->session->request_time,
+ 'taken_time' => $booking->session->taken_time,
+ ];
+ }
+
+ if ($booking->type === 'EX' && $booking->exams) {
+ $examinerName = 'Unknown';
+
+ // Safely get mentor name and ID
+ if ($booking->exams->mentor) {
+ $examinerName = $booking->exams->examiner->name.' ('.$booking->exams->examiner->cid.')';
+ }
+
+ $booking->exams_details = [
+ 'id' => $booking->exams->id,
+ 'position' => $booking->exams->position,
+ 'student_id' => $booking->exams->student_id,
+ 'exmr_id' => $booking->exams->exmr_id,
+ 'examiner' => $examinerName,
+ 'date' => $booking->exams->date_1,
+ 'from' => $booking->exams->from_1,
+ 'to' => $booking->exams->to_1,
+ 'time_book' => $booking->exams->time_book,
+ 'taken_time' => $booking->exams->time_taken,
+ ];
+ }
+
return $booking;
});
diff --git a/resources/assets/js/bookings.js b/resources/assets/js/bookings.js
new file mode 100644
index 0000000000..f6e043460e
--- /dev/null
+++ b/resources/assets/js/bookings.js
@@ -0,0 +1,213 @@
+document.addEventListener('DOMContentLoaded', function () {
+ // ---- Display old bookings: toggle a CSS class on
----
+ const oldCb = document.getElementById('filter-old');
+ if (oldCb) {
+ function syncOldToggle() {
+ document.body.classList.toggle('show-old', !!oldCb.checked);
+ }
+ syncOldToggle();
+ oldCb.addEventListener('change', syncOldToggle);
+ }
+
+ // ---- Type filters ----
+ const typeCheckboxes = Array.from(document.querySelectorAll('input[type="checkbox"][data-filter]'));
+ function applyTypeFilters() {
+ const allowed = new Set(typeCheckboxes.filter(cb => cb.checked).map(cb => cb.getAttribute('data-filter')));
+ document.querySelectorAll('.booking-entry').forEach(el => {
+ const kind = el.getAttribute('data-kind') || 'normal';
+ el.style.display = allowed.has(kind) ? '' : 'none';
+ });
+ }
+ typeCheckboxes.forEach(cb => cb.addEventListener('change', applyTypeFilters));
+ applyTypeFilters();
+
+ // ---- Live clocks + TZ message ----
+ function updateTimes() {
+ const now = new Date();
+ const local = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ const utc = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'UTC' });
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ const offsetMinutes = -now.getTimezoneOffset(); // + ahead, - behind
+ const absMinutes = Math.abs(offsetMinutes);
+ const hours = Math.floor(absMinutes / 60);
+ const minutes = absMinutes % 60;
+
+ let relation, hint;
+ if (offsetMinutes === 0) {
+ relation = "the same as Zulu.";
+ hint = "No conversion is needed.";
+ } else if (offsetMinutes > 0) {
+ relation = `${hours}h${minutes ? ` ${minutes}m` : ""} ahead of Zulu.`;
+ hint = `To convert any time on Bookings to your local time, add ${hours}h${minutes ? ` ${minutes}m` : ""}.`;
+ } else {
+ relation = `${hours}h${minutes ? ` ${minutes}m` : ""} behind Zulu.`;
+ hint = `To convert any time on Bookings to your local time, subtract ${hours}h${minutes ? ` ${minutes}m` : ""}.`;
+ }
+
+ document.querySelectorAll('#local-time').forEach(el => el.textContent = local);
+ document.querySelectorAll('#utc-time').forEach(el => el.textContent = utc);
+ document.querySelectorAll('#tz-message').forEach(el => el.innerHTML =
+ `Your local time (${tz}) is ${relation}
${hint}`);
+ }
+ updateTimes();
+ setInterval(updateTimes, 1000);
+
+ // ---- Promote bookings to .is-past as time passes ----
+ function rollPastOverTime() {
+ const now = new Date();
+ document.querySelectorAll('.booking-entry:not(.is-past)').forEach(el => {
+ const span = el.querySelector('.booking-time');
+ if (!span) return;
+ const endISO = span.dataset.end;
+ if (!endISO) return;
+ const end = new Date(endISO);
+ if (!isNaN(end) && end <= now) el.classList.add('is-past');
+ });
+ }
+ rollPastOverTime();
+ setInterval(rollPastOverTime, 60000);
+
+ // ---- Local time toggle ----
+ const toggleLocalTime = document.getElementById('toggle-localtime');
+ if (toggleLocalTime) {
+ function updateBookingTimes() {
+ const useLocal = toggleLocalTime.checked;
+ document.querySelectorAll('.booking-time').forEach(span => {
+ const startISO = span.dataset.start;
+ const endISO = span.dataset.end;
+ if (!startISO || !endISO) return;
+
+ const startUtc = new Date(startISO);
+ const endUtc = new Date(endISO);
+ if (isNaN(startUtc) || isNaN(endUtc)) return;
+
+ if (useLocal) {
+ const startLocal = startUtc.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ const endLocal = endUtc.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ span.textContent = `${startLocal} - ${endLocal} (${tzName})`;
+ } else {
+ const startZulu = startUtc.toISOString().substr(11, 5);
+ const endZulu = endUtc.toISOString().substr(11, 5);
+ span.textContent = `${startZulu}z - ${endZulu}z`;
+ }
+ });
+ }
+ updateBookingTimes();
+ toggleLocalTime.addEventListener('change', updateBookingTimes);
+ }
+
+ // ---- Tooltip containers ----
+ const items = Array.from(document.querySelectorAll('.tooltip-container'));
+
+ // Hide all except optional "keep" element
+ function hideAll(keep = null) {
+ items.forEach(el => {
+ if (el !== keep) {
+ el.classList.remove('is-open');
+ if (el === document.activeElement) el.blur();
+ }
+ });
+ }
+
+ items.forEach(el => {
+ // Open on hover, but ensure only one is active
+ el.addEventListener('mouseenter', () => {
+ hideAll(el);
+ // purely hover should still show via your CSS .tooltip-container:hover .tooltip-content
+ // we don't set is-open here to keep hover behavior unchanged
+ });
+
+ // Keyboard focus: keep only one open
+ el.addEventListener('focus', () => hideAll(el));
+
+ // Click to "pin" (toggle). Only one pinned at a time.
+ el.addEventListener('click', (e) => {
+ const willOpen = !el.classList.contains('is-open');
+ hideAll(willOpen ? el : null);
+ el.classList.toggle('is-open', willOpen);
+ // Prevent the click from bubbling to the document "outside click" handler
+ e.stopPropagation();
+ });
+
+ // If you leave with the mouse and it's not pinned (no .is-open), let it close
+ el.addEventListener('mouseleave', () => {
+ if (!el.classList.contains('is-open') && !el.matches(':focus')) {
+ // hover-out: your CSS already hides, nothing needed
+ }
+ });
+
+ // When focus is lost, unpin
+ el.addEventListener('blur', () => el.classList.remove('is-open'));
+ });
+
+ // Click outside → close any pinned tooltip
+ document.addEventListener('click', () => hideAll());
+
+ // ESC to close
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') hideAll();
+ });
+
+ const containers = Array.from(document.querySelectorAll('.tooltip-container'));
+
+ function positionTip(container) {
+ const tip = container.querySelector('.tooltip-content');
+ if (!tip) return;
+
+ // Reset any previous inline transforms/positions
+ tip.style.transform = '';
+ tip.classList.remove('pos-above', 'pos-below', 'pos-left', 'pos-right');
+
+ // Start with a sensible default (to the right, below)
+ tip.classList.add('pos-right', 'pos-below');
+
+ // Wait a frame so the browser can measure with classes applied
+ requestAnimationFrame(() => {
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const tipRect = tip.getBoundingClientRect();
+ const contRect = container.getBoundingClientRect();
+
+ // Decide vertical placement
+ let vertical = 'below';
+ if (tipRect.bottom > vh && contRect.top > tipRect.height) vertical = 'above';
+ tip.classList.toggle('pos-below', vertical === 'below');
+ tip.classList.toggle('pos-above', vertical === 'above');
+
+ // Re-measure if we changed vertical
+ let r = tip.getBoundingClientRect();
+
+ // Decide horizontal placement
+ let horizontal = 'right';
+ if (r.right > vw && contRect.left > r.width) horizontal = 'left';
+ tip.classList.toggle('pos-right', horizontal === 'right');
+ tip.classList.toggle('pos-left', horizontal === 'left');
+
+ // Final nudge to keep fully on-screen
+ r = tip.getBoundingClientRect();
+ let dx = 0, dy = 0;
+ if (r.left < 0) dx += -r.left + 8;
+ if (r.right > vw) dx += vw - r.right - 8;
+ if (r.top < 0) dy += -r.top + 8;
+ if (r.bottom > vh) dy += vh - r.bottom - 8;
+ if (dx || dy) tip.style.transform = `translate(${dx}px, ${dy}px)`;
+ });
+ }
+
+ // Position when tip becomes visible (hover/focus/pin)
+ containers.forEach(c => {
+ c.addEventListener('mouseenter', () => positionTip(c));
+ c.addEventListener('focus', () => positionTip(c), true);
+ c.addEventListener('click', () => positionTip(c)); // if you support click-to-pin
+ });
+
+ // Reposition any open ones on resize/scroll
+ const repack = () => {
+ document.querySelectorAll('.tooltip-container.is-open, .tooltip-container:hover, .tooltip-container:focus-within')
+ .forEach(c => positionTip(c));
+ };
+ window.addEventListener('resize', repack, { passive: true });
+ window.addEventListener('scroll', repack, { passive: true });
+});
diff --git a/resources/assets/sass/app.scss b/resources/assets/sass/app.scss
index d047a86a64..830d97f359 100644
--- a/resources/assets/sass/app.scss
+++ b/resources/assets/sass/app.scss
@@ -11,7 +11,8 @@ $screen-sm: 802px;
@import "_top-notification";
@import "_markdown";
-body, html {
+body,
+html {
margin: 0 auto;
font-size: 14px;
font-family: Calibri, Tahoma, Geneva, sans-serif;
@@ -34,15 +35,16 @@ body, html {
height: 70%;
float: left;
margin: 10px 15px 0 5.0%;
+
img {
height: 100%;
}
}
.dev_environment_notification {
- height: 20px;
- background-color: red;
- text-align: center;
+ height: 20px;
+ background-color: red;
+ text-align: center;
}
.sys_notification {
@@ -51,13 +53,13 @@ body, html {
position: relative;
.text {
- margin: 0;
- position: absolute;
- top: 50%;
- -ms-transform: translateY(-50%);
- transform: translateY(-50%);
- text-align: center;
- width: 100%;
+ margin: 0;
+ position: absolute;
+ top: 50%;
+ -ms-transform: translateY(-50%);
+ transform: translateY(-50%);
+ text-align: center;
+ width: 100%;
}
}
@@ -69,6 +71,7 @@ body, html {
transform-style: preserve-3d;
transform: perspective(auto) translateZ(-30vw) scale(1.4);
perspective: 1000;
+
img {
margin-top: 90px;
height: 260px;
@@ -78,8 +81,9 @@ body, html {
.fuzzy_date {
color: #337ab7;
text-decoration: none;
- &:hover{
- cursor:pointer;
+
+ &:hover {
+ cursor: pointer;
}
}
@@ -87,11 +91,11 @@ body, html {
color: white !important;
}
-.vertical-center{
+.vertical-center {
vertical-align: middle !important;
}
-.table.table-borders >tbody > tr > td {
+.table.table-borders>tbody>tr>td {
border: 1px solid grey;
}
@@ -110,7 +114,8 @@ body, html {
width: 90%;
}
-.breadcrumb_content_left, .breadcrumb_content_right {
+.breadcrumb_content_left,
+.breadcrumb_content_right {
margin: 0 auto;
padding-top: 8px;
height: 100%;
@@ -167,6 +172,7 @@ body, html {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
+ line-clamp: 3;
-webkit-box-orient: vertical;
}
@@ -206,7 +212,9 @@ body, html {
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
- > .panel-heading, > .panel-heading-link > .panel-heading {
+
+ >.panel-heading,
+ >.panel-heading-link>.panel-heading {
background-color: #17375e;
border-color: #17375E;
color: white;
@@ -227,36 +235,42 @@ body, html {
padding: 5px;
}
- > .panel-title a {
+ >.panel-title a {
color: white;
}
}
+
.panel-body .row-text-contain {
word-break: break-word;
}
- .panel-footer.panel-footer-primary {
- color: white;
- background-color: $brand-primary;
- a {
- color: white;
- }
- a:hover {
- color: $gray-lighter;
- }
+
+ .panel-footer.panel-footer-primary {
+ color: white;
+ background-color: $brand-primary;
+
+ a {
+ color: white;
}
+
+ a:hover {
+ color: $gray-lighter;
+ }
+ }
}
.panel-uk-danger {
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
- > .panel-heading {
+
+ >.panel-heading {
background-color: #c30c1d;
border-color: #840712;
color: white;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
+
.panel-body .row-text-contain {
word-break: break-word;
}
@@ -266,20 +280,24 @@ body, html {
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
- > .panel-heading {
+
+ >.panel-heading {
background-color: #009700;
border-color: #006900;
color: white;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
+
.panel-body .row-text-contain {
word-break: break-word;
}
}
-.panel-group#unreadNotifications, .panel-group#readNotifications {
+.panel-group#unreadNotifications,
+.panel-group#readNotifications {
padding: 10px;
+
.panel {
margin-bottom: 15px;
}
@@ -310,18 +328,181 @@ body, html {
.profile-picture {
object-fit: cover;
border-radius: 50px;
- height:50px;
+ height: 50px;
width: 50px;
}
.equal {
- display: flex;
- display: -webkit-flex;
- flex-wrap: wrap;
+ display: flex;
+ display: -webkit-flex;
+ flex-wrap: wrap;
}
.flex-centered {
- display: flex;
- justify-content: center;
- align-items: center;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.calendar {
+ width: 100%;
+ max-width: 850px;
+ margin: 0 auto;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+
+.calendar th,
+.calendar td {
+ width: 14.28%;
+ height: 100px;
+ text-align: center;
+ font-weight: bold;
+ vertical-align: top;
+ padding: 5px;
+ border: 1px solid #ccc;
+ box-sizing: border-box;
+}
+
+.bg-gray-100 {
+ background-color: #f0f0f0;
+}
+
+.booking-entry {
+ cursor: pointer;
+ text-decoration: none;
+ font-size: 15px;
+ margin-top: 5px;
+}
+
+.booking-entry:hover {
+ text-decoration: underline;
+}
+
+.today-cell {
+ background-color: #FFFFCC !important;
+ border: 2px solid #FFCC00 !important;
+}
+
+.booking {
+ color: #336633;
+ border-color: #336633;
+ padding: 4px;
+ border: 2px solid transparent;
+ border-radius: 4px;
+}
+
+.booking-mentoring {
+ color: #7429C7;
+ border-color: #7429C7;
+}
+
+.booking-event {
+ color: #FF0000;
+ border-color: #FF0000;
+}
+
+.booking-exam {
+ color: #993300;
+ border-color: #993300;
+}
+
+.booking-seminar {
+ color: #FFA500;
+ border-color: #FFA500;
+}
+
+.tooltip-container {
+ position: relative;
+ display: inline-block;
+}
+
+.booking-entry.is-past {
+ display: none !important;
+}
+
+.show-old .booking-entry.is-past {
+ display: block !important;
+}
+
+
+.tooltip-content {
+ display: none;
+ position: absolute;
+ z-index: 1000;
+ left: 100%;
+ top: 0;
+ min-width: 250px;
+ background: #fff;
+ color: #222;
+ border: 1px solid #888;
+ border-radius: 6px;
+ padding: 10px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ white-space: normal;
+ font-size: 13px;
+}
+
+.tooltip-container:hover .tooltip-content,
+.tooltip-container:focus-within .tooltip-content {
+ display: block;
+}
+
+.tooltip-container.is-open .tooltip-content {
+ display: block;
+}
+
+/* keep your existing tooltip styles; add these utility positions */
+.tooltip-container {
+ position: relative;
+}
+
+.tooltip-content {
+ will-change: transform;
+}
+
+.tooltip-content.pos-right {
+ left: 100%;
+ top: 0;
+ right: auto;
+ bottom: auto;
+ margin-left: 8px;
+}
+
+.tooltip-content.pos-left {
+ right: 100%;
+ left: auto;
+ top: 0;
+ bottom: auto;
+ margin-right: 8px;
+}
+
+.tooltip-content.pos-below {
+ top: 100%;
+ bottom: auto;
+ left: 0;
+ right: auto;
+ margin-top: 8px;
}
+
+.tooltip-content.pos-above {
+ bottom: 100%;
+ top: auto;
+ left: 0;
+ right: auto;
+ margin-bottom: 8px;
+}
+
+/* ensure pinned tips (from your click-to-pin code) also show */
+.tooltip-container.is-open .tooltip-content {
+ display: block;
+}
+
+.no-x-overflow {
+ overflow-x: visible !important;
+}
+
+.legend-table td {
+ vertical-align: middle !important;
+ padding: 6px 10px !important;
+}
\ No newline at end of file
diff --git a/resources/views/components/nav.blade.php b/resources/views/components/nav.blade.php
index c66af76c31..318de24ad2 100644
--- a/resources/views/components/nav.blade.php
+++ b/resources/views/components/nav.blade.php
@@ -101,7 +101,6 @@
-
Pilots
diff --git a/resources/views/site/bookings/index.blade.php b/resources/views/site/bookings/index.blade.php
new file mode 100644
index 0000000000..1c94b1ccbd
--- /dev/null
+++ b/resources/views/site/bookings/index.blade.php
@@ -0,0 +1,237 @@
+@extends('layout')
+
+@section('content')
+
+@php
+ use Carbon\Carbon;
+ use App\Libraries\Bookings;
+
+ if (is_string($date)) {
+ $date = Carbon::parse($date);
+ }
+
+ $typeRowStyles = [
+ 'mentoring' => 'bg-blue-100 border-blue-400',
+ 'EX' => 'color:red; bg-red-100 border-red-400 font-bold',
+ 'solo' => 'bg-yellow-100 border-yellow-400',
+ 'normal' => 'bg-gray-100 border-gray-300',
+ ];
+
+ // Current time in Zulu (UTC) – used server-side to mark past bookings
+ $nowUtc = Carbon::now('UTC');
+@endphp
+
+
+
+
+ Bookings Calendar
+
+
+
+
+ The bookings calendar shows the availability of our controllers for bookings.
+ You can navigate through the months using the links below.
+
+
+
+
+
+
+
+
+
+ Information
+
+
+
+
All times on Bookings are in UTC (Zulu).
+
+ Your local time is:
+ (Local)
+
+ The current Zulu time is
+ (UTC/Zulu)
+
+
+
+
+
+
+
+
+
+
{{ $date->format('F Y') }}
+
+
+
Use the links below to navigate through the months.
+
← Previous
+ |
+
Next →
+
+
+
+
+
+
+ @foreach(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as $day)
+ | {{ $day }} |
+ @endforeach
+
+
+
+ @foreach($calendar as $week)
+
+ @foreach($week as $day)
+ @php
+ $dayDate = is_string($day['date']) ? Carbon::parse($day['date']) : $day['date'];
+ @endphp
+
+ {{ $dayDate->day }}
+
+
+ @foreach($day['bookings'] as $booking)
+ @php
+ $type = $booking->type ?? 'normal';
+ $rowClass = $typeRowStyles[$type] ?? 'bg-white border-gray-200';
+
+ $bookingTypeClass = '';
+ if ($booking->isMentoring()) $bookingTypeClass = 'booking-mentoring';
+ elseif ($booking->isEvent()) $bookingTypeClass = 'booking-event';
+ elseif ($booking->isExam()) $bookingTypeClass = 'booking-exam';
+ elseif ($booking->isSeminar()) $bookingTypeClass = 'booking-seminar';
+ else $bookingTypeClass = 'booking';
+
+ $fromTime = Carbon::parse($booking->from)->format('H:i');
+ $toTime = Carbon::parse($booking->to)->format('H:i');
+ $tooltipHtml = !$booking->isEvent()
+ ? view('site.bookings.partials.tooltip', compact('booking', 'dayDate', 'fromTime', 'toTime'))->render()
+ : null;
+
+ // Normalized kind for legend filters
+ $kind = 'normal';
+ if ($booking->isEvent()) $kind = 'event';
+ elseif ($booking->isExam()) $kind = 'exam';
+ elseif ($booking->isMentoring()) $kind = 'mentoring';
+ elseif ($booking->isSeminar()) $kind = 'seminar';
+
+ // Server-side "is past?" (UTC) via pure function
+ $isPast = Bookings::isPastUtc($dayDate->copy(), $booking->to, $nowUtc);
+ @endphp
+
+
+ {{ strtoupper($booking->position) ?? 'Booking' }}
+
+
+ {{ $fromTime }}z - {{ $toTime }}z
+
+
+ @if(!$booking->isEvent())
+ {!! $tooltipHtml !!}
+ @endif
+
+ @endforeach
+ |
+ @endforeach
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+
+ Calendar Legend / Filter
+
+
+
+
+
+
+
+
+ Booking Information
+
+
+
+ Hover over a booking to view more information about the session.
+ Displayed information may vary depending on booking type.
+
+
+
+
+
+@section('scripts')
+ @vite(['resources/assets/js/bookings.js'])
+@endsection
+
+@stop
diff --git a/resources/views/site/bookings/partials/tooltip.blade.php b/resources/views/site/bookings/partials/tooltip.blade.php
new file mode 100644
index 0000000000..e5a163166e
--- /dev/null
+++ b/resources/views/site/bookings/partials/tooltip.blade.php
@@ -0,0 +1,91 @@
+@php
+use Carbon\Carbon;
+
+if (!function_exists('formatDate')) {
+ function formatDate($datetime) {
+ return $datetime ? Carbon::parse($datetime)->format('d/m/Y H:i:s') : 'N/A';
+ }
+}
+
+// Default values
+$displayType = 'Position Booking';
+$displayName = $booking->member ? $booking->member->name : 'Unknown';
+$bookedLabel = 'Booked on';
+$showMentor = false;
+$showExaminer = false;
+$mentor = 'N/A';
+$examiner = 'N/A';
+$requestTime = 'N/A';
+$takenTime = 'N/A';
+
+// EX (Practical Exam)
+if ($booking->type === 'EX') {
+ $displayType = 'Confirmed Practical Exam';
+ $displayName = 'HIDDEN';
+ $bookedLabel = 'Requested on';
+ $showExaminer = true;
+
+ if ($booking->exams && $booking->exams->examiner) {
+ $examiner = $booking->exams->examiner->name . ' (' . $booking->exams->examiner->cid . ')';
+ }
+
+ $requestTime = $booking->exams ? formatDate($booking->exams->time_book) : 'N/A';
+ $takenTime = $booking->exams ? formatDate($booking->exams->time_taken) : 'N/A';
+}
+
+// ME (Mentoring Session)
+if ($booking->type === 'ME') {
+ $displayType = 'Confirmed Mentoring Session';
+ $displayName = 'HIDDEN';
+ $bookedLabel = 'Requested on';
+ $showMentor = true;
+
+ if ($booking->session && $booking->session->mentor) {
+ $mentor = $booking->session->mentor->name . ' (' . $booking->session->mentor->cid . ')';
+ }
+
+ $requestTime = $booking->session ? formatDate($booking->session->request_time) : 'N/A';
+ $takenTime = $booking->session ? formatDate($booking->session->taken_time) : 'N/A';
+}
+
+if ($booking->type === 'GS') {
+ $displayType = 'Seminar Booking';
+ $bookedLabel = 'Booked on';
+ $showMentor = false;
+
+ if ($booking->session && $booking->session->mentor) {
+ $mentor = $booking->session->mentor->name . ' (' . $booking->session->mentor->cid . ')';
+ }
+
+ $requestTime = $booking->session ? formatDate($booking->session->request_time) : 'N/A';
+ $takenTime = $booking->session ? formatDate($booking->session->taken_time) : 'N/A';
+}
+
+$timeBooked = formatDate($booking->time_booked);
+@endphp
+
+
+
Booking Information
+
Booking Type: {{ $displayType }}
+
Position: {{ $booking->position }}
+
Date: {{ $dayDate->format('D jS M Y') }}
+
Book Time: {{ $fromTime }} - {{ $toTime }}
+
+
Booked By: {{ $displayName }}
+ @if($showExaminer)
+
Requested on: {{ $requestTime }}
+
+
Examiner: {{ $examiner }}
+ @elseif($showMentor)
+
Requested on: {{ $requestTime }}
+
+
Mentor: {{ $mentor }}
+
Accepted on: {{ $takenTime }}
+ @else
+
{{ $bookedLabel }}: {{ $timeBooked }}
+ @endif
+
+ @if(!empty($booking->notes))
+
Notes: {{ $booking->notes }}
+ @endif
+
\ No newline at end of file
diff --git a/resources/views/site/home.blade.php b/resources/views/site/home.blade.php
index c663bd18f3..60e9ed8cb0 100644
--- a/resources/views/site/home.blade.php
+++ b/resources/views/site/home.blade.php
@@ -126,7 +126,7 @@
Events
diff --git a/routes/web-public.php b/routes/web-public.php
index f8f2456ece..63c0928738 100644
--- a/routes/web-public.php
+++ b/routes/web-public.php
@@ -68,4 +68,12 @@
Route::get('/s1-syllabus')->uses('PolicyPagesController@viewS1Syllabus')->name('s1-syllabus');
});
});
+
+ Route::group([
+ 'as' => 'bookings.',
+ 'prefix' => 'bookings',
+ ], function () {
+ Route::get('calendar/{year?}/{month?}')->uses('BookingsPagesController@index')->name('index');
+ Route::get('/{id}')->uses('BookingsPagesController@show')->name('bookings.show');
+ });
});
diff --git a/tests/Feature/Bookings/CalendarViewPastClassTest.php b/tests/Feature/Bookings/CalendarViewPastClassTest.php
new file mode 100644
index 0000000000..71ec36bdca
--- /dev/null
+++ b/tests/Feature/Bookings/CalendarViewPastClassTest.php
@@ -0,0 +1,157 @@
+type = $type;
+ $this->from = $from;
+ $this->to = $to;
+ $this->position = $position;
+
+ // Provide defaults the tooltip can read
+ $this->member = $member ?: (object) [
+ 'cid' => '123456',
+ 'name' => 'Test User',
+ ];
+
+ // time the booking was made (string expected by view)
+ $this->time_booked = $time_booked ?: '2025-10-10 09:00:00';
+ }
+
+ public function isEvent(): bool
+ {
+ return $this->type === 'EV';
+ }
+
+ public function isExam(): bool
+ {
+ return $this->type === 'EX';
+ }
+
+ public function isMentoring(): bool
+ {
+ return $this->type === 'ME';
+ }
+
+ public function isSeminar(): bool
+ {
+ return $this->type === 'SE';
+ }
+
+ /** Return null for any field the view asks for that we didn't stub explicitly */
+ public function __get($name)
+ {
+ return null;
+ }
+}
+
+class CalendarViewPastClassTest extends TestCase
+{
+ protected function tearDown(): void
+ {
+ Carbon::setTestNow(); // clear frozen time
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function past_bookings_have_is_past_class_and_future_dont()
+ {
+ // Freeze "now" in UTC so server-side isPast logic is deterministic
+ Carbon::setTestNow(Carbon::parse('2025-10-12 12:00:00', 'UTC'));
+
+ $date = Carbon::parse('2025-10-12'); // the month being rendered
+ $prevMonth = $date->copy()->subMonth();
+ $nextMonth = $date->copy()->addMonth();
+
+ $today = Carbon::parse('2025-10-12');
+ $yesterday = $today->copy()->subDay();
+ $tomorrow = $today->copy()->addDay();
+
+ // Build the minimal calendar structure the Blade expects
+ $calendar = [[
+ [
+ 'date' => $yesterday->toDateString(),
+ 'bookings' => [
+ new BookingStub('normal', '2025-10-11 10:00:00', '2025-10-11 11:00:00', 'EGLL_TWR'),
+ ],
+ ],
+ [
+ 'date' => $today->toDateString(),
+ 'bookings' => [
+ // Past today
+ new BookingStub('normal', '2025-10-12 10:00:00', '2025-10-12 11:00:00', 'EGKK_GND'),
+ // Future today
+ new BookingStub('normal', '2025-10-12 12:01:00', '2025-10-12 13:00:00', 'EGKK_TWR'),
+ ],
+ ],
+ [
+ 'date' => $tomorrow->toDateString(),
+ 'bookings' => [
+ new BookingStub('normal', '2025-10-13 09:00:00', '2025-10-13 10:00:00', 'EGLL_APP'),
+ ],
+ ],
+ ]];
+
+ // Render the Blade with all required variables
+ $html = view('site.bookings.index', [
+ 'calendar' => $calendar,
+ 'date' => $date,
+ 'prevMonth' => $prevMonth,
+ 'nextMonth' => $nextMonth,
+ ])->render();
+
+ // Smoke checks
+ $this->assertStringContainsString('booking-entry', $html);
+
+ // A booking from yesterday should be marked as past
+ $this->assertStringContainsString('is-past', $html, 'Expected at least one .is-past element for past bookings.');
+
+ // Future-today (EGKK_TWR) should be present but not marked past
+ $this->assertStringContainsString('EGKK_TWR', $html);
+ $this->assertStringNotContainsString('EGKK_TWR" class="is-past', $html);
+
+ // Tomorrow (EGLL_APP) should not be marked past
+ $this->assertStringContainsString('EGLL_APP', $html);
+ $this->assertStringNotContainsString('EGLL_APP" class="is-past', $html);
+ }
+}
diff --git a/tests/Unit/Bookings/BookingsTest.php b/tests/Unit/Bookings/BookingsTest.php
new file mode 100644
index 0000000000..dcd7729e46
--- /dev/null
+++ b/tests/Unit/Bookings/BookingsTest.php
@@ -0,0 +1,102 @@
+assertTrue(Bookings::isPastUtc($day, $end, $nowUtc));
+ }
+
+ #[Test]
+ public function today_before_now_is_past()
+ {
+ $nowUtc = Carbon::parse('2025-10-12 12:00:00', 'UTC');
+ $day = Carbon::parse('2025-10-12', 'UTC');
+ $end = '2025-10-12 11:59:00';
+
+ $this->assertTrue(Bookings::isPastUtc($day, $end, $nowUtc));
+ }
+
+ #[Test]
+ public function today_equal_now_is_past()
+ {
+ $nowUtc = Carbon::parse('2025-10-12 12:00:00', 'UTC');
+ $day = Carbon::parse('2025-10-12', 'UTC');
+ $end = '2025-10-12 12:00:00';
+
+ $this->assertTrue(Bookings::isPastUtc($day, $end, $nowUtc));
+ }
+
+ #[Test]
+ public function today_after_now_is_not_past()
+ {
+ $nowUtc = Carbon::parse('2025-10-12 12:00:00', 'UTC');
+ $day = Carbon::parse('2025-10-12', 'UTC');
+ $end = '2025-10-12 12:01:00';
+
+ $this->assertFalse(Bookings::isPastUtc($day, $end, $nowUtc));
+ }
+
+ #[Test]
+ public function future_day_is_not_past()
+ {
+ $nowUtc = Carbon::parse('2025-10-12 12:00:00', 'UTC');
+ $day = Carbon::parse('2025-10-13', 'UTC');
+ $end = '2025-10-13 08:00:00';
+
+ $this->assertFalse(Bookings::isPastUtc($day, $end, $nowUtc));
+ }
+
+ #[Test]
+ public function timezone_on_booking_end_is_respected()
+ {
+ // Now is 12:00 UTC
+ $nowUtc = Carbon::parse('2025-10-12 12:00:00', 'UTC');
+ $day = Carbon::parse('2025-10-12', 'UTC');
+
+ // End at 13:00 local (+02:00) => 11:00 UTC -> should be past
+ $endPastTz = '2025-10-12T13:00:00+02:00';
+ $this->assertTrue(Bookings::isPastUtc($day, $endPastTz, $nowUtc));
+
+ // End at 13:30 local (+02:00) => 11:30 UTC -> still past
+ $endStillPastTz = '2025-10-12T13:30:00+02:00';
+ $this->assertTrue(Bookings::isPastUtc($day, $endStillPastTz, $nowUtc));
+
+ // End at 16:00 local (+02:00) => 14:00 UTC -> not past
+ $endFutureTz = '2025-10-12T16:00:00+02:00';
+ $this->assertFalse(Bookings::isPastUtc($day, $endFutureTz, $nowUtc));
+ }
+
+ #[Test]
+ public function midnight_boundary_cases()
+ {
+ // At midnight UTC at the start of 2025-10-12
+ $nowUtc = Carbon::parse('2025-10-12 00:00:00', 'UTC');
+
+ // Previous day: always past
+ $yesterday = Carbon::parse('2025-10-11', 'UTC');
+ $endYesterday = '2025-10-11 23:59:59';
+ $this->assertTrue(Bookings::isPastUtc($yesterday, $endYesterday, $nowUtc));
+
+ // Today, end exactly at midnight -> should be past (<= now)
+ $today = Carbon::parse('2025-10-12', 'UTC');
+ $endAtMidnight = '2025-10-12 00:00:00';
+ $this->assertTrue(Bookings::isPastUtc($today, $endAtMidnight, $nowUtc));
+
+ // Today, 00:00:01 -> not past
+ $endJustAfter = '2025-10-12 00:00:01';
+ $this->assertFalse(Bookings::isPastUtc($today, $endJustAfter, $nowUtc));
+ }
+}
diff --git a/tests/Unit/CTS/BookingsRepositoryTest.php b/tests/Unit/CTS/BookingsRepositoryTest.php
index ab5013c966..dfaeff0094 100644
--- a/tests/Unit/CTS/BookingsRepositoryTest.php
+++ b/tests/Unit/CTS/BookingsRepositoryTest.php
@@ -15,13 +15,13 @@ class BookingsRepositoryTest extends TestCase
{
use DatabaseTransactions;
- /* @var BookingRepository */
+ /** @var BookingRepository */
protected $subjectUnderTest;
- /* @var Carbon */
+ /** @var string */
protected $today;
- /* @var Carbon */
+ /** @var string */
protected $tomorrow;
protected function setUp(): void
@@ -36,72 +36,113 @@ protected function setUp(): void
#[Test]
public function it_can_return_a_list_of_bookings_for_today()
{
- Booking::factory()->count(10)->create(['date' => Carbon::now()]);
+ // Ensure created items are on the same date the repo will query
+ Booking::factory()->count(10)->create([
+ 'date' => $this->today,
+ 'from' => '10:00',
+ 'to' => '11:00',
+ 'type' => 'BK',
+ ]);
$bookings = $this->subjectUnderTest->getBookings(Carbon::parse($this->today));
- $this->assertInstanceOf(Collection::class, $bookings);
+ // Repo returns a Collection...
+ $this->assertInstanceOf(\Illuminate\Support\Collection::class, $bookings);
$this->assertCount(10, $bookings);
+
+ // ...of Eloquent Booking models (mutated/formatted by formatBookings)
$this->assertInstanceOf(Booking::class, $bookings->first());
}
#[Test]
public function it_can_return_a_list_of_todays_bookings_with_owner_and_type()
{
- Booking::factory()->count(2)->create(['date' => $this->knownDate->copy()->addDays(5)->toDateString()]);
+ // Noise on a different date
+ \App\Models\Cts\Booking::factory()->count(2)->create([
+ 'date' => $this->knownDate->copy()->addDays(5)->toDateString(),
+ 'from' => '10:00',
+ 'to' => '11:00',
+ 'type' => 'BK',
+ ]);
- $bookingTodayOne = Booking::Factory()->create([
+ $bookingTodayOne = \App\Models\Cts\Booking::factory()->create([
'id' => '96155',
'date' => $this->today,
'from' => '17:00',
- 'member_id' => Member::Factory()->create()->id,
+ 'to' => '18:00',
+ 'member_id' => \App\Models\Cts\Member::factory()->create()->id,
'type' => 'BK',
]);
- $bookingTodayTwo = Booking::Factory()->create([
+ $bookingTodayTwo = \App\Models\Cts\Booking::factory()->create([
'id' => '96156',
'date' => $this->today,
'from' => '18:00',
- 'member_id' => Member::Factory()->create()->id,
+ 'to' => '19:00',
+ 'member_id' => \App\Models\Cts\Member::factory()->create()->id,
'type' => 'ME',
]);
$bookings = $this->subjectUnderTest->getTodaysBookings();
- $this->assertInstanceOf(Collection::class, $bookings);
+ $this->assertInstanceOf(\Illuminate\Support\Collection::class, $bookings);
$this->assertCount(2, $bookings);
- $this->assertEquals([
+ // Build expected minimal payloads
+ $expected0 = [
'id' => $bookingTodayOne->id,
- 'date' => $this->today,
- 'from' => Carbon::parse($bookingTodayOne->from)->format('H:i'),
- 'to' => Carbon::parse($bookingTodayOne->to)->format('H:i'),
+ 'date' => $this->today, // repo formats to Y-m-d
+ 'from' => \Carbon\Carbon::parse($bookingTodayOne->from)->format('H:i'),
+ 'to' => \Carbon\Carbon::parse($bookingTodayOne->to)->format('H:i'),
'position' => $bookingTodayOne->position,
'type' => $bookingTodayOne->type,
'member' => [
- 'id' => $bookingTodayOne['member']['cid'],
- 'name' => $bookingTodayOne['member']['name'],
+ 'id' => $bookingTodayOne->member->cid,
+ 'name' => $bookingTodayOne->member->name,
],
- ], $bookings->get(0)->toArray());
- $this->assertEquals([
+ ];
+
+ $expected1 = [
'id' => $bookingTodayTwo->id,
'date' => $this->today,
- 'from' => Carbon::parse($bookingTodayTwo->from)->format('H:i'),
- 'to' => Carbon::parse($bookingTodayTwo->to)->format('H:i'),
+ 'from' => \Carbon\Carbon::parse($bookingTodayTwo->from)->format('H:i'),
+ 'to' => \Carbon\Carbon::parse($bookingTodayTwo->to)->format('H:i'),
'position' => $bookingTodayTwo->position,
'type' => $bookingTodayTwo->type,
'member' => [
- 'id' => $bookingTodayTwo['member']['cid'],
- 'name' => $bookingTodayTwo['member']['name'],
+ 'id' => $bookingTodayTwo->member->cid,
+ 'name' => $bookingTodayTwo->member->name,
],
- ], $bookings->get(1)->toArray());
+ ];
+
+ // Only compare the keys we care about (Eloquent adds many others)
+ $actual0 = collect($bookings->get(0)->toArray())->only(array_keys($expected0))->toArray();
+ $actual1 = collect($bookings->get(1)->toArray())->only(array_keys($expected1))->toArray();
+
+ // For nested 'member', also narrow to the expected keys
+ $actual0['member'] = collect($actual0['member'])->only(['id', 'name'])->toArray();
+ $actual1['member'] = collect($actual1['member'])->only(['id', 'name'])->toArray();
+
+ $this->assertEquals($expected0, $actual0);
+ $this->assertEquals($expected1, $actual1);
}
#[Test]
public function it_hides_member_details_on_exam_booking()
{
- $normalBooking = Booking::Factory()->create(['date' => $this->today, 'from' => '17:00', 'type' => 'BK']);
- Booking::Factory()->create(['date' => $this->today, 'from' => '18:00', 'type' => 'EX']);
+ $normalBooking = Booking::factory()->create([
+ 'date' => $this->today,
+ 'from' => '17:00',
+ 'to' => '18:00',
+ 'type' => 'BK',
+ ]);
+
+ Booking::factory()->create([
+ 'date' => $this->today,
+ 'from' => '18:00',
+ 'to' => '19:00',
+ 'type' => 'EX',
+ ]);
$bookings = $this->subjectUnderTest->getTodaysBookings();
@@ -119,11 +160,11 @@ public function it_hides_member_details_on_exam_booking()
#[Test]
public function it_can_return_a_list_of_todays_live_atc_bookings()
{
- Booking::Factory()->create(['date' => $this->today, 'position' => 'EGKK_APP']); // Live ATC booking today
- Booking::Factory()->create(['date' => $this->today, 'position' => 'EGKK_SBAT']); // Sweatbox ATC booking today
- Booking::Factory()->create(['date' => $this->today, 'position' => 'P1_VATSIM']); // Pilot booking today
- Booking::Factory()->create(['date' => $this->tomorrow, 'position' => 'EGKK_APP']); // ATC booking tomorrow
- Booking::Factory()->create(['date' => $this->tomorrow, 'position' => 'P1_VATSIM']); // Pilot booking tomorrw
+ Booking::factory()->create(['date' => $this->today, 'position' => 'EGKK_APP', 'from' => '10:00', 'to' => '11:00']); // live ATC
+ Booking::factory()->create(['date' => $this->today, 'position' => 'EGKK_SBAT', 'from' => '12:00', 'to' => '13:00']); // sweatbox
+ Booking::factory()->create(['date' => $this->today, 'position' => 'P1_VATSIM', 'from' => '14:00', 'to' => '15:00']); // pilot
+ Booking::factory()->create(['date' => $this->tomorrow, 'position' => 'EGKK_APP', 'from' => '10:00', 'to' => '11:00']);
+ Booking::factory()->create(['date' => $this->tomorrow, 'position' => 'P1_VATSIM', 'from' => '12:00', 'to' => '13:00']);
$atcBookings = $this->subjectUnderTest->getTodaysLiveAtcBookings();
@@ -134,19 +175,27 @@ public function it_can_return_a_list_of_todays_live_atc_bookings()
#[Test]
public function it_can_return_a_booking_without_a_known_member()
{
- Booking::Factory()->create(['date' => $this->today, 'member_id' => 0, 'type' => 'BK']);
+ Booking::factory()->create([
+ 'date' => $this->today,
+ 'from' => '10:00',
+ 'to' => '11:00',
+ 'member_id' => 0,
+ 'type' => 'BK',
+ ]);
- $this->subjectUnderTest->getTodaysLiveAtcBookings();
+ $bookings = $this->subjectUnderTest->getTodaysBookings();
- $this->expectNotToPerformAssertions();
+ $this->assertEquals('', $bookings->first()['member']['id']);
+ $this->assertEquals('Unknown', $bookings->first()['member']['name']);
}
#[Test]
public function it_returns_bookings_in_start_time_order()
{
- $afternoon = Booking::Factory()->create(['date' => $this->today, 'from' => '16:00', 'to' => '17:00', 'type' => 'BK']);
- $morning = Booking::Factory()->create(['date' => $this->today, 'from' => '09:00', 'to' => '11:00', 'type' => 'BK']);
- $night = Booking::Factory()->create(['date' => $this->today, 'from' => '22:00', 'to' => '23:00', 'type' => 'BK']);
+ // Ensure these are "live ATC" positions so both methods return same set
+ $afternoon = Booking::factory()->create(['date' => $this->today, 'from' => '16:00', 'to' => '17:00', 'type' => 'BK', 'position' => 'EGLL_TWR']);
+ $morning = Booking::factory()->create(['date' => $this->today, 'from' => '09:00', 'to' => '11:00', 'type' => 'BK', 'position' => 'EGLL_TWR']);
+ $night = Booking::factory()->create(['date' => $this->today, 'from' => '22:00', 'to' => '23:00', 'type' => 'BK', 'position' => 'EGLL_TWR']);
$todaysBookings = $this->subjectUnderTest->getTodaysBookings();
$todaysAtcBookings = $this->subjectUnderTest->getTodaysLiveAtcBookings();
diff --git a/vite.config.js b/vite.config.js
index 2c3c9cd3cc..6f631262d5 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,5 +1,5 @@
-import {defineConfig} from 'vite';
-import laravel, {refreshPaths} from 'laravel-vite-plugin'
+import { defineConfig } from 'vite';
+import laravel, { refreshPaths } from 'laravel-vite-plugin'
export default defineConfig({
plugins: [
@@ -12,6 +12,7 @@ export default defineConfig({
'resources/assets/sass/home.scss',
'resources/assets/js/home.js',
'resources/assets/js/snow.js',
+ 'resources/assets/js/bookings.js',
'resources/assets/js/top-notification.js'
],
refresh: [
@@ -20,7 +21,7 @@ export default defineConfig({
}),
{
name: 'blade',
- handleHotUpdate({file, server}) {
+ handleHotUpdate({ file, server }) {
if (file.endsWith('.blade.php')) {
server.ws.send({
type: 'full-reload',