Initial commit

This commit is contained in:
Richard Jolley committed 2026-01-14 00:54:08 +00:00
commit c909d539bc
7178 files changed
+756624

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Services;
use App\Models\ExternalBusyWindow;
use Carbon\Carbon;
class AvailabilityService
{
public function __construct(
private readonly string $tz = 'Europe/London',
private readonly int $bufferMins = 40
) {}
/**
* Check if a single slot (date + time) is blocked by external busy windows,
* considering a travel buffer before start and after end.
*/
public function isSlotBlockedByExternalBusy(string $dateYmd, string $timeHi, int $durationMins = 60): bool
{
// Build slot window in UTC
$slotStartLocal = Carbon::createFromFormat('Y-m-d H:i', "{$dateYmd} {$timeHi}", $this->tz);
$slotStartUtc = $slotStartLocal->clone()->utc();
$slotEndUtc = $slotStartUtc->clone()->addMinutes($durationMins);
// Expand comparison bounds by buffer
$slotStartMinusBuffer = $slotStartUtc->clone()->subMinutes($this->bufferMins);
$slotEndPlusBuffer = $slotEndUtc->clone()->addMinutes($this->bufferMins);
// Overlap: busyStart < slotEnd+buffer AND busyEnd > slotStart-buffer
return ExternalBusyWindow::query()
->where('starts_at', '<', $slotEndPlusBuffer)
->where('ends_at', '>', $slotStartMinusBuffer)
->exists();
}
/**
* For block-booking: given a set of future candidate dates for a given time,
* return the subset that are blocked (with reasons).
*
* @param array<string> $dateListYmd e.g. ['2025-10-27','2025-11-03', ...]
* @return array<int, array{date:string, blocked:bool}>
*/
public function checkSeriesConflicts(array $dateListYmd, string $timeHi, int $durationMins = 60): array
{
$results = [];
foreach ($dateListYmd as $ymd) {
$blocked = $this->isSlotBlockedByExternalBusy($ymd, $timeHi, $durationMins);
$results[] = [
'date' => $ymd,
'blocked' => $blocked,
];
}
return $results;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services;
use App\Models\ExternalBusyWindow;
use Carbon\Carbon;
class BusyWindowService
{
public static function getBusyRanges()
{
// Fetch current + upcoming busy windows
return ExternalBusyWindow::where('ends_at', '>=', now())
->get()
->map(fn($w) => [
'start' => Carbon::parse($w->starts_at)->subMinutes(40),
'end' => Carbon::parse($w->ends_at)->addMinutes(40),
]);
}
public static function isInBusyWindow($slotStart, $busyRanges, $slotLengthMinutes = 60)
{
$slotEnd = (clone $slotStart)->addMinutes($slotLengthMinutes);
foreach ($busyRanges as $range) {
// If the slot overlaps the busy window in any way
if ($slotStart < $range['end'] && $slotEnd > $range['start']) {
return true;
}
}
return false;
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Services;
use App\Models\Slot;
use Carbon\Carbon;
class CalendarService
{
/**
* Build calendar grid for Mon–Fri with your six daily times.
*
* @param Carbon $start Monday of the week you want to display
* @param Carbon $now Current time (use 'Europe/London')
* @param bool $applyShortNotice If true, mark ≤ 120 min as closed (student view)
* @return array{headers: array<int, array{date:string,label:string}>, rows: array<int, array{time:string,label:string,cells:array<int,array{date:string,time:string,slot:int|null,status:string,class:string,name:?string}>}>, times: string[]}
*/
public function build(Carbon $start, Carbon $now, bool $applyShortNotice): array
{
$end = $start->copy()->addDays(6);
$weekdays = [1,2,3,4,5]; // Mon..Fri
$times = ['09:00:00','10:00:00','11:00:00','14:00:00','15:00:00','16:00:00'];
// Get all slots with bookings in the visible week
$slots = Slot::with(['bookings' => function ($q) use ($start, $end) {
$q->whereBetween('date', [$start->toDateString(), $end->toDateString()]);
}])->get();
// Map slots by weekday/time for O(1) lookup in the loop
$map = [];
foreach ($slots as $s) {
$map[(int)$s->weekday][$s->time] = $s;
}
// Column headers
$headers = [];
for ($i = 0; $i < 5; $i++) {
$d = $start->copy()->addDays($i);
$headers[] = [
'date' => $d->toDateString(),
'label' => $d->format('D d M'),
];
}
// Rows (each time)
$rows = [];
foreach ($times as $time) {
$cells = [];
foreach ($weekdays as $wd) {
$date = $start->copy()->addDays($wd - 1)->toDateString();
$slot = $map[$wd][$time] ?? null;
$booking = $slot ? $slot->bookings->firstWhere('date', $date) : null;
// Compute short-notice closure only when required (students)
$sessionStart = Carbon::createFromFormat('Y-m-d H:i:s', "{$date} {$time}", $now->timezone);
$minutesUntil = $now->diffInMinutes($sessionStart, false);
$tooSoon = $applyShortNotice ? ($minutesUntil <= 120) : false;
// Decide status + cell class
$status = 'closed';
$class = 'bg-gray-100';
$name = null;
if ($booking) {
if ($booking->status === 'booked') { $status = 'booked'; $class = 'bg-red-200'; }
elseif ($booking->status === 'blocked') { $status = 'blocked'; $class = 'bg-yellow-200'; }
} elseif (!$slot) {
$status = 'not_seeded'; // slot wasn’t seeded for this weekday/time
} elseif ($minutesUntil < 0 || $tooSoon) {
$status = 'closed';
} else {
$status = 'free';
$class = 'bg-green-100';
}
if ($booking && $booking->student_name) {
$name = $booking->student_name;
}
$cells[] = [
'date' => $date,
'time' => $time,
'slot' => $slot?->id,
'status' => $status, // free | booked | blocked | closed | not_seeded
'class' => $class,
'name' => $name,
];
}
$rows[] = [
'time' => $time,
'label' => substr($time, 0, 5),
'cells' => $cells,
];
}
return [
'headers' => $headers,
'rows' => $rows,
'times' => $times,
];
}
}