From 19e663d6fa9ed65cc23e5b052d99ce3bc07a8deb Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 5 Jun 2026 15:38:58 -0300 Subject: [PATCH] Extend availability (durations, weekly recurrence, calendar); price offerings in dollars Availability (#2): - us_availability gains offering_id, duration_minutes (default 60), and recurrence_group; AvailabilitySlot carries the new fields. - AvailabilityRepository::createWeeklySeries() generates N weekly rows sharing a recurrence_group; findAvailable() filters by offering and duration. Date math uses DateTimeImmutable::modify() (the no-debug CI regex `dd\(` matches `->add(`). - REST GET filters by offering_id/duration_minutes; POST accepts duration_minutes, offering_id, recurrence (single|weekly) + weeks. - Admin form adds duration, an offering picker, and one-off/weekly options (OfferingRepository wired into AvailabilityController). - booking.js renders an agenda calendar (slots grouped by day, with duration). The richer booking UX lands with the booking-flow work. Offering price in dollars: - Switch us_offerings.price_cents (INT) to price DECIMAL(10,2); Offering uses float $price. Admin form and REST take dollars. - Fix a pre-existing misalignment in the Offering insert/update $wpdb format arrays (billing_mode/capacity/is_active were mapped to the wrong specifiers, which would corrupt values) via a single COLUMN_FORMATS list. Also bump PHPStan to --memory-limit=1G in the lint script; 128M now crashes analysis as the codebase has grown. Refs #2 Co-Authored-By: Claude Opus 4.8 --- assets/js/booking.js | 42 +++++++++- docs/features/offerings.md | 2 +- src/AdminMenu.php | 2 +- src/Availability/AvailabilityController.php | 46 +++++++++-- src/Availability/AvailabilityEndpoint.php | 53 ++++++++++-- src/Availability/AvailabilityRepository.php | 80 +++++++++++++++++-- src/Availability/AvailabilitySlot.php | 29 ++++--- src/Offering/Offering.php | 6 +- src/Offering/OfferingController.php | 2 +- src/Offering/OfferingEndpoint.php | 8 +- src/Offering/OfferingRepository.php | 15 +++- src/Schema.php | 21 +++-- templates/admin/availability.php | 36 ++++++++- templates/admin/offerings.php | 6 +- .../AvailabilityRepositoryTest.php | 76 +++++++++++++++--- .../Availability/AvailabilitySlotTest.php | 68 +++++++++------- .../Unit/Offering/OfferingRepositoryTest.php | 6 +- tests/Unit/Offering/OfferingTest.php | 12 +-- 18 files changed, 398 insertions(+), 112 deletions(-) diff --git a/assets/js/booking.js b/assets/js/booking.js index 892e95a..e5f14ad 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -30,16 +30,50 @@ errorBox.style.display = 'block'; } + function dayKey(dt) { + return String(dt).slice(0, 10); + } + + function timeOf(dt) { + return String(dt).slice(11, 16); + } + + function dayLabel(key) { + const date = new Date(key + 'T00:00:00'); + if (Number.isNaN(date.getTime())) return key; + return date.toLocaleDateString(undefined, { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + }); + } + + function groupByDay(slots) { + const groups = new Map(); + slots.forEach((slot) => { + const key = dayKey(slot.start_dt); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(slot); + }); + return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); + } + + // Agenda-style calendar: available slots grouped by day. The richer booking + // UX (offering selection, intake questions, policy acceptance) lands with the + // booking-flow work. function renderSlots(slots) { if (!slots.length) { slotList.innerHTML = '

No available lesson slots at this time.

'; return; } - slotList.innerHTML = slots.map((slot) => ` -
- ${escHtml(slot.start_dt)} – ${escHtml(slot.end_dt)} - + slotList.innerHTML = groupByDay(slots).map(([key, daySlots]) => ` +
+

${escHtml(dayLabel(key))}

+ ${daySlots.map((slot) => ` +
+ ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))} (${escHtml(String(slot.duration_minutes))} min) + +
+ `).join('')}
`).join(''); diff --git a/docs/features/offerings.md b/docs/features/offerings.md index a5aed82..be2770e 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -13,7 +13,7 @@ An offering is anything a student can register for: a private-lesson type (30 or | `title` | VARCHAR(191) | Display name | | `description` | TEXT | Optional longer description | | `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes | -| `price_cents` | INT UNSIGNED | Price in the smallest currency unit | +| `price` | DECIMAL(10,2) | Price in dollars | | `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` | | `billing_mode` | VARCHAR(20) | `one_time` (single booking) or `full_term` (weekly / group) | | `allow_weekly` | TINYINT(1) | Private only — may be reserved weekly for the term | diff --git a/src/AdminMenu.php b/src/AdminMenu.php index d7ea139..b057b69 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -26,7 +26,7 @@ class AdminMenu { private PolicyController $policyController; public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService ) { - $this->availabilityController = new AvailabilityController( $availability ); + $this->availabilityController = new AvailabilityController( $availability, $offerings ); $this->lessonController = new LessonController( $bookings ); $this->offeringController = new OfferingController( $offerings ); $this->questionController = new QuestionController( $questions, $offerings ); diff --git a/src/Availability/AvailabilityController.php b/src/Availability/AvailabilityController.php index c653e36..fdd3997 100644 --- a/src/Availability/AvailabilityController.php +++ b/src/Availability/AvailabilityController.php @@ -4,10 +4,15 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Availability; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Offering\Offering; +use Unsupervised\Schedular\Offering\OfferingRepository; class AvailabilityController { - public function __construct( private AvailabilityRepository $repository ) {} + public function __construct( + private AvailabilityRepository $repository, + private OfferingRepository $offerings, + ) {} public function renderPage(): void { if ( ! current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) { @@ -20,7 +25,8 @@ class AvailabilityController { $this->handleFormAction( $instructorId ); } - $slots = $this->repository->findByInstructor( $instructorId ); + $slots = $this->repository->findByInstructor( $instructorId ); + $offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true ); include USC_PLUGIN_DIR . 'templates/admin/availability.php'; } @@ -31,12 +37,7 @@ class AvailabilityController { $action = sanitize_key( wp_unslash( $_POST['usc_action'] ?? '' ) ); if ( 'add' === $action ) { - $startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) ); - $endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) ); - - if ( '' !== $startDt && '' !== $endDt ) { - $this->repository->insert( new AvailabilitySlot( $instructorId, $startDt, $endDt ) ); - } + $this->addSlot( $instructorId ); } if ( 'delete' === $action ) { @@ -50,4 +51,33 @@ class AvailabilityController { } // phpcs:enable WordPress.Security.NonceVerification.Missing } + + private function addSlot( int $instructorId ): void { + // phpcs:disable WordPress.Security.NonceVerification.Missing + $startDt = sanitize_text_field( wp_unslash( $_POST['start_dt'] ?? '' ) ); + $endDt = sanitize_text_field( wp_unslash( $_POST['end_dt'] ?? '' ) ); + + if ( '' === $startDt || '' === $endDt ) { + return; + } + + $offeringId = absint( $_POST['offering_id'] ?? 0 ); + $duration = absint( $_POST['duration_minutes'] ?? 0 ); + + $slot = new AvailabilitySlot( + instructorId: $instructorId, + startDt: $startDt, + endDt: $endDt, + durationMinutes: $duration > 0 ? $duration : 60, + offeringId: $offeringId > 0 ? $offeringId : null, + ); + + if ( 'weekly' === sanitize_key( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) ) { + $this->repository->createWeeklySeries( $slot, absint( $_POST['weeks'] ?? 1 ) ); + return; + } + + $this->repository->insert( $slot ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + } } diff --git a/src/Availability/AvailabilityEndpoint.php b/src/Availability/AvailabilityEndpoint.php index 5066ba5..3b8e308 100644 --- a/src/Availability/AvailabilityEndpoint.php +++ b/src/Availability/AvailabilityEndpoint.php @@ -19,15 +19,23 @@ class AvailabilityEndpoint { 'callback' => [ $this, 'index' ], 'permission_callback' => [ $this, 'canBook' ], 'args' => [ - 'instructor_id' => [ + 'instructor_id' => [ 'type' => 'integer', 'default' => 0, ], - 'from' => [ + 'offering_id' => [ + 'type' => 'integer', + 'default' => 0, + ], + 'duration_minutes' => [ + 'type' => 'integer', + 'default' => 0, + ], + 'from' => [ 'type' => 'string', 'default' => '', ], - 'to' => [ + 'to' => [ 'type' => 'string', 'default' => '', ], @@ -38,16 +46,32 @@ class AvailabilityEndpoint { 'callback' => [ $this, 'create' ], 'permission_callback' => [ $this, 'canManage' ], 'args' => [ - 'start_dt' => [ + 'start_dt' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ], - 'end_dt' => [ + 'end_dt' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ], + 'duration_minutes' => [ + 'type' => 'integer', + 'default' => 60, + ], + 'offering_id' => [ + 'type' => 'integer', + 'default' => 0, + ], + 'recurrence' => [ + 'type' => 'string', + 'default' => 'single', + ], + 'weeks' => [ + 'type' => 'integer', + 'default' => 1, + ], ], ], ] @@ -69,6 +93,8 @@ class AvailabilityEndpoint { public function index( \WP_REST_Request $request ): \WP_REST_Response { $slots = $this->repository->findAvailable( (int) $request->get_param( 'instructor_id' ), + (int) $request->get_param( 'offering_id' ), + (int) $request->get_param( 'duration_minutes' ), (string) $request->get_param( 'from' ), (string) $request->get_param( 'to' ), ); @@ -77,12 +103,23 @@ class AvailabilityEndpoint { } public function create( \WP_REST_Request $request ): \WP_REST_Response { + $offeringId = absint( $request->get_param( 'offering_id' ) ); + $duration = absint( $request->get_param( 'duration_minutes' ) ); + $slot = new AvailabilitySlot( - instructorId: get_current_user_id(), - startDt: (string) $request->get_param( 'start_dt' ), - endDt: (string) $request->get_param( 'end_dt' ), + instructorId: get_current_user_id(), + startDt: (string) $request->get_param( 'start_dt' ), + endDt: (string) $request->get_param( 'end_dt' ), + durationMinutes: $duration > 0 ? $duration : 60, + offeringId: $offeringId > 0 ? $offeringId : null, ); + if ( 'weekly' === $request->get_param( 'recurrence' ) ) { + $ids = $this->repository->createWeeklySeries( $slot, absint( $request->get_param( 'weeks' ) ) ); + + return new \WP_REST_Response( [ 'ids' => $ids ], 201 ); + } + $id = $this->repository->insert( $slot ); return new \WP_REST_Response( [ 'id' => $id ], 201 ); diff --git a/src/Availability/AvailabilityRepository.php b/src/Availability/AvailabilityRepository.php index aa5f428..236e640 100644 --- a/src/Availability/AvailabilityRepository.php +++ b/src/Availability/AvailabilityRepository.php @@ -15,24 +15,78 @@ class AvailabilityRepository { $this->db->insert( $this->table, [ - 'instructor_id' => $slot->instructorId, - 'start_dt' => $slot->startDt, - 'end_dt' => $slot->endDt, - 'is_booked' => 0, - 'created_at' => current_time( 'mysql' ), + 'instructor_id' => $slot->instructorId, + 'offering_id' => $slot->offeringId, + 'start_dt' => $slot->startDt, + 'end_dt' => $slot->endDt, + 'duration_minutes' => $slot->durationMinutes, + 'is_booked' => 0, + 'recurrence_group' => $slot->recurrenceGroup, + 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%s', '%s', '%d', '%s' ] + [ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ] ); return $this->db->insert_id; } /** - * Find unbooked slots, optionally filtered by instructor and date range. + * Create a weekly-recurring series from a template slot. Each occurrence is a + * separate row one week apart, all sharing a `recurrence_group` (the id of the + * first row). + * + * @return list Inserted slot IDs. + */ + public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array { + $occurrences = max( 1, $occurrences ); + $start = new \DateTimeImmutable( $first->startDt ); + $end = new \DateTimeImmutable( $first->endDt ); + + $ids = []; + $groupId = 0; + + for ( $week = 0; $week < $occurrences; $week++ ) { + $shift = '+' . ( 7 * $week ) . ' days'; + + $id = $this->insert( + new AvailabilitySlot( + instructorId: $first->instructorId, + startDt: $start->modify( $shift )->format( 'Y-m-d H:i:s' ), + endDt: $end->modify( $shift )->format( 'Y-m-d H:i:s' ), + durationMinutes: $first->durationMinutes, + offeringId: $first->offeringId, + recurrenceGroup: $groupId > 0 ? $groupId : null, + ) + ); + + if ( 0 === $groupId ) { + $groupId = $id; + $this->setRecurrenceGroup( $id, $groupId ); + } + + $ids[] = $id; + } + + return $ids; + } + + private function setRecurrenceGroup( int $id, int $groupId ): void { + $this->db->update( + $this->table, + [ 'recurrence_group' => $groupId ], + [ 'id' => $id ], + [ '%d' ], + [ '%d' ] + ); + } + + /** + * Find unbooked slots, optionally filtered by instructor, offering, lesson + * length, and date range. * * @return list */ - public function findAvailable( int $instructorId = 0, string $from = '', string $to = '' ): array { + public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array { $where = [ 'is_booked = 0' ]; $params = []; @@ -41,6 +95,16 @@ class AvailabilityRepository { $params[] = $instructorId; } + if ( $offeringId > 0 ) { + $where[] = 'offering_id = %d'; + $params[] = $offeringId; + } + + if ( $durationMinutes > 0 ) { + $where[] = 'duration_minutes = %d'; + $params[] = $durationMinutes; + } + if ( '' !== $from ) { $where[] = 'start_dt >= %s'; $params[] = $from; diff --git a/src/Availability/AvailabilitySlot.php b/src/Availability/AvailabilitySlot.php index c10a33f..e0afced 100644 --- a/src/Availability/AvailabilitySlot.php +++ b/src/Availability/AvailabilitySlot.php @@ -9,17 +9,23 @@ class AvailabilitySlot { public readonly int $instructorId, public readonly string $startDt, public readonly string $endDt, + public readonly int $durationMinutes = 60, + public readonly ?int $offeringId = null, public readonly bool $isBooked = false, + public readonly ?int $recurrenceGroup = null, public readonly ?int $id = null, ) {} public static function fromRow( object $row ): self { return new self( - instructorId: (int) $row->instructor_id, - startDt: $row->start_dt, - endDt: $row->end_dt, - isBooked: (bool) $row->is_booked, - id: (int) $row->id, + instructorId: (int) $row->instructor_id, + startDt: $row->start_dt, + endDt: $row->end_dt, + durationMinutes: (int) $row->duration_minutes, + offeringId: null !== $row->offering_id ? (int) $row->offering_id : null, + isBooked: (bool) $row->is_booked, + recurrenceGroup: null !== $row->recurrence_group ? (int) $row->recurrence_group : null, + id: (int) $row->id, ); } @@ -30,11 +36,14 @@ class AvailabilitySlot { */ public function toArray(): array { return [ - 'id' => $this->id, - 'instructor_id' => $this->instructorId, - 'start_dt' => $this->startDt, - 'end_dt' => $this->endDt, - 'is_booked' => $this->isBooked, + 'id' => $this->id, + 'instructor_id' => $this->instructorId, + 'offering_id' => $this->offeringId, + 'start_dt' => $this->startDt, + 'end_dt' => $this->endDt, + 'duration_minutes' => $this->durationMinutes, + 'is_booked' => $this->isBooked, + 'recurrence_group' => $this->recurrenceGroup, ]; } } diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index c73db77..918c6dc 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -29,7 +29,7 @@ class Offering { public readonly int $instructorId, public readonly string $kind, public readonly string $title, - public readonly int $priceCents = 0, + public readonly float $price = 0.0, public readonly string $currency = 'CAD', public readonly string $billingMode = self::BILLING_ONE_TIME, public readonly ?string $description = null, @@ -48,7 +48,7 @@ class Offering { instructorId: (int) $row->instructor_id, kind: $row->kind, title: $row->title, - priceCents: (int) $row->price_cents, + price: (float) $row->price, currency: $row->currency, billingMode: $row->billing_mode, description: $row->description, @@ -76,7 +76,7 @@ class Offering { 'title' => $this->title, 'description' => $this->description, 'duration_minutes' => $this->durationMinutes, - 'price_cents' => $this->priceCents, + 'price' => $this->price, 'currency' => $this->currency, 'billing_mode' => $this->billingMode, 'allow_weekly' => $this->allowWeekly, diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php index c1e8646..f187b58 100644 --- a/src/Offering/OfferingController.php +++ b/src/Offering/OfferingController.php @@ -71,7 +71,7 @@ class OfferingController { instructorId: $instructorId, kind: $kind, title: $title, - priceCents: absint( $_POST['price_cents'] ?? 0 ), + price: max( 0.0, (float) sanitize_text_field( wp_unslash( $_POST['price'] ?? '0' ) ) ), billingMode: $billingMode, durationMinutes: $duration > 0 ? $duration : null, allowWeekly: isset( $_POST['allow_weekly'] ), diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index 457f354..a1bcb49 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -85,7 +85,7 @@ class OfferingEndpoint { instructorId: get_current_user_id(), kind: $kind, title: $title, - priceCents: absint( $request->get_param( 'price_cents' ) ), + price: $this->price( $request->get_param( 'price' ) ), currency: sanitize_text_field( (string) ( $request->get_param( 'currency' ) ?? 'CAD' ) ), billingMode: $billingMode, description: $this->nullableText( $request->get_param( 'description' ) ), @@ -129,7 +129,7 @@ class OfferingEndpoint { instructorId: $existing->instructorId, kind: $kind, title: $request->has_param( 'title' ) ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : $existing->title, - priceCents: $request->has_param( 'price_cents' ) ? absint( $request->get_param( 'price_cents' ) ) : $existing->priceCents, + price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price, currency: $request->has_param( 'currency' ) ? sanitize_text_field( (string) $request->get_param( 'currency' ) ) : $existing->currency, billingMode: $billingMode, description: $request->has_param( 'description' ) ? $this->nullableText( $request->get_param( 'description' ) ) : $existing->description, @@ -182,6 +182,10 @@ class OfferingEndpoint { return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] ); } + private function price( mixed $value ): float { + return max( 0.0, (float) $value ); + } + private function nullableInt( mixed $value ): ?int { return ( null === $value || '' === $value ) ? null : (int) $value; } diff --git a/src/Offering/OfferingRepository.php b/src/Offering/OfferingRepository.php index 5142f4f..baa3f3a 100644 --- a/src/Offering/OfferingRepository.php +++ b/src/Offering/OfferingRepository.php @@ -11,11 +11,20 @@ class OfferingRepository { $this->table = $db->prefix . 'us_offerings'; } + /** + * Column formats aligned to {@see columns()} (instructor_id, kind, title, + * description, duration_minutes, price, currency, billing_mode, allow_weekly, + * capacity, term_start, term_end, schedule_note, is_active). + * + * @var list + */ + private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ]; + public function insert( Offering $offering ): int { $this->db->insert( $this->table, $this->columns( $offering ) + [ 'created_at' => current_time( 'mysql' ) ], - [ '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s' ] + [ ...self::COLUMN_FORMATS, '%s' ] ); return $this->db->insert_id; @@ -26,7 +35,7 @@ class OfferingRepository { $this->table, $this->columns( $offering ), [ 'id' => $id ], - [ '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ], + self::COLUMN_FORMATS, [ '%d' ] ); } @@ -43,7 +52,7 @@ class OfferingRepository { 'title' => $offering->title, 'description' => $offering->description, 'duration_minutes' => $offering->durationMinutes, - 'price_cents' => $offering->priceCents, + 'price' => $offering->price, 'currency' => $offering->currency, 'billing_mode' => $offering->billingMode, 'allow_weekly' => $offering->allowWeekly ? 1 : 0, diff --git a/src/Schema.php b/src/Schema.php index 4a41888..009c069 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -13,15 +13,20 @@ class Schema { public static function tables( string $prefix, string $charset ): array { return [ "CREATE TABLE {$prefix}us_availability ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - instructor_id BIGINT UNSIGNED NOT NULL, - start_dt DATETIME NOT NULL, - end_dt DATETIME NOT NULL, - is_booked TINYINT(1) NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + instructor_id BIGINT UNSIGNED NOT NULL, + offering_id BIGINT UNSIGNED DEFAULT NULL, + start_dt DATETIME NOT NULL, + end_dt DATETIME NOT NULL, + duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 60, + is_booked TINYINT(1) NOT NULL DEFAULT 0, + recurrence_group BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY instructor_id (instructor_id), - KEY start_dt (start_dt) + KEY offering_id (offering_id), + KEY start_dt (start_dt), + KEY recurrence_group (recurrence_group) ) {$charset};", "CREATE TABLE {$prefix}us_lessons ( @@ -45,7 +50,7 @@ class Schema { title VARCHAR(191) NOT NULL, description TEXT, duration_minutes SMALLINT UNSIGNED DEFAULT NULL, - price_cents INT UNSIGNED NOT NULL DEFAULT 0, + price DECIMAL(10,2) NOT NULL DEFAULT 0, currency VARCHAR(3) NOT NULL DEFAULT 'CAD', billing_mode VARCHAR(20) NOT NULL DEFAULT 'one_time', allow_weekly TINYINT(1) NOT NULL DEFAULT 0, diff --git a/templates/admin/availability.php b/templates/admin/availability.php index 9686dc2..6921b8c 100644 --- a/templates/admin/availability.php +++ b/templates/admin/availability.php @@ -5,7 +5,10 @@ if (! defined('ABSPATH')) { exit; } -/** @var list<\Unsupervised\Schedular\Model\AvailabilitySlot> $slots */ +/** + * @var list<\Unsupervised\Schedular\Availability\AvailabilitySlot> $slots + * @var list<\Unsupervised\Schedular\Offering\Offering> $offeringChoices + */ ?>

@@ -23,6 +26,35 @@ if (! defined('ABSPATH')) { + + + + + + + + + + + + + + + + +   + + + + @@ -37,6 +69,7 @@ if (! defined('ABSPATH')) { + @@ -46,6 +79,7 @@ if (! defined('ABSPATH')) { startDt); ?> endDt); ?> + durationMinutes . ' min'); ?> isBooked ? esc_html__('Booked', 'unsupervised-schedular') : esc_html__('Available', 'unsupervised-schedular'); ?> isBooked) : ?> diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php index 1b12fd4..3e4f5f3 100644 --- a/templates/admin/offerings.php +++ b/templates/admin/offerings.php @@ -35,8 +35,8 @@ if (! defined('ABSPATH')) { - - + + @@ -86,7 +86,7 @@ if (! defined('ABSPATH')) { title); ?> kind); ?> durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?> - priceCents / 100, 2) . ' ' . $offering->currency); ?> + price, 2) . ' ' . $offering->currency); ?> billingMode); ?> isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?> diff --git a/tests/Unit/Availability/AvailabilityRepositoryTest.php b/tests/Unit/Availability/AvailabilityRepositoryTest.php index 83c5232..99f0ae0 100644 --- a/tests/Unit/Availability/AvailabilityRepositoryTest.php +++ b/tests/Unit/Availability/AvailabilityRepositoryTest.php @@ -34,19 +34,50 @@ class AvailabilityRepositoryTest extends TestCase Mockery::on(static function (array $data): bool { return $data['instructor_id'] === 5 && $data['start_dt'] === '2026-04-01 09:00:00' + && $data['duration_minutes'] === 30 + && $data['offering_id'] === 8 && $data['is_booked'] === 0; }), - ['%d', '%s', '%s', '%d', '%s'] + ['%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s'] ); $this->db->insert_id = 42; - $slot = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00'); + $slot = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8); $result = $this->repo->insert($slot); self::assertSame(42, $result); } + public function testCreateWeeklySeriesInsertsWeeklyAndSharesGroup(): void + { + Functions\when('current_time')->justReturn('2026-04-07 12:00:00'); + + $captured = []; + $ids = [10, 11, 12]; + + $this->db->shouldReceive('insert') + ->times(3) + ->andReturnUsing(function (string $table, array $data) use (&$captured, &$ids): void { + $captured[] = $data['start_dt']; + $this->db->insert_id = array_shift($ids); + }); + + // The first row is back-filled with its own id as the recurrence group. + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_availability', ['recurrence_group' => 10], ['id' => 10], ['%d'], ['%d']); + + $first = new AvailabilitySlot(5, '2026-04-07 09:00:00', '2026-04-07 10:00:00', 60); + $result = $this->repo->createWeeklySeries($first, 3); + + self::assertSame([10, 11, 12], $result); + self::assertSame( + ['2026-04-07 09:00:00', '2026-04-14 09:00:00', '2026-04-21 09:00:00'], + $captured + ); + } + public function testFindByIdReturnsNullWhenNotFound(): void { $this->db->shouldReceive('prepare') @@ -65,11 +96,14 @@ class AvailabilityRepositoryTest extends TestCase public function testFindByIdReturnsSlotWhenFound(): void { $row = (object) [ - 'id' => '10', - 'instructor_id' => '5', - 'start_dt' => '2026-04-01 09:00:00', - 'end_dt' => '2026-04-01 10:00:00', - 'is_booked' => '0', + 'id' => '10', + 'instructor_id' => '5', + 'offering_id' => null, + 'start_dt' => '2026-04-01 09:00:00', + 'end_dt' => '2026-04-01 10:00:00', + 'duration_minutes' => '60', + 'is_booked' => '0', + 'recurrence_group' => null, ]; $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); @@ -128,14 +162,32 @@ class AvailabilityRepositoryTest extends TestCase $this->repo->findAvailable(instructorId: 3); } + public function testFindAvailableWithOfferingAndDurationFilters(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/offering_id = %d AND duration_minutes = %d/'), + Mockery::on(static fn (array $p): bool => $p === [8, 30]) + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([]); + + $this->repo->findAvailable(offeringId: 8, durationMinutes: 30); + } + public function testFindByInstructorReturnsSlots(): void { $row = (object) [ - 'id' => '5', - 'instructor_id' => '3', - 'start_dt' => '2026-04-01 09:00:00', - 'end_dt' => '2026-04-01 10:00:00', - 'is_booked' => '0', + 'id' => '5', + 'instructor_id' => '3', + 'offering_id' => null, + 'start_dt' => '2026-04-01 09:00:00', + 'end_dt' => '2026-04-01 10:00:00', + 'duration_minutes' => '60', + 'is_booked' => '0', + 'recurrence_group' => null, ]; $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); diff --git a/tests/Unit/Availability/AvailabilitySlotTest.php b/tests/Unit/Availability/AvailabilitySlotTest.php index 81b9487..71b15e5 100644 --- a/tests/Unit/Availability/AvailabilitySlotTest.php +++ b/tests/Unit/Availability/AvailabilitySlotTest.php @@ -11,58 +11,66 @@ class AvailabilitySlotTest extends TestCase public function testConstructorAndProperties(): void { $slot = new AvailabilitySlot( - instructorId: 5, - startDt: '2026-04-01 09:00:00', - endDt: '2026-04-01 10:00:00', - isBooked: false, - id: 42, + instructorId: 5, + startDt: '2026-04-01 09:00:00', + endDt: '2026-04-01 10:00:00', + durationMinutes: 30, + offeringId: 8, + isBooked: false, + recurrenceGroup: 100, + id: 42, ); self::assertSame(5, $slot->instructorId); self::assertSame('2026-04-01 09:00:00', $slot->startDt); - self::assertSame('2026-04-01 10:00:00', $slot->endDt); + self::assertSame(30, $slot->durationMinutes); + self::assertSame(8, $slot->offeringId); + self::assertSame(100, $slot->recurrenceGroup); self::assertFalse($slot->isBooked); self::assertSame(42, $slot->id); } - public function testFromRowMapsCorrectly(): void + public function testDefaults(): void + { + $slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00'); + + self::assertSame(60, $slot->durationMinutes); + self::assertNull($slot->offeringId); + self::assertFalse($slot->isBooked); + self::assertNull($slot->recurrenceGroup); + self::assertNull($slot->id); + } + + public function testFromRowMapsCorrectlyAndCastsNullables(): void { $row = (object) [ - 'id' => '7', - 'instructor_id' => '3', - 'start_dt' => '2026-05-10 14:00:00', - 'end_dt' => '2026-05-10 15:00:00', - 'is_booked' => '1', + 'id' => '7', + 'instructor_id' => '3', + 'offering_id' => null, + 'start_dt' => '2026-05-10 14:00:00', + 'end_dt' => '2026-05-10 15:00:00', + 'duration_minutes' => '60', + 'is_booked' => '1', + 'recurrence_group' => '7', ]; $slot = AvailabilitySlot::fromRow($row); self::assertSame(7, $slot->id); self::assertSame(3, $slot->instructorId); + self::assertNull($slot->offeringId); + self::assertSame(60, $slot->durationMinutes); + self::assertSame(7, $slot->recurrenceGroup); self::assertTrue($slot->isBooked); } public function testToArrayContainsExpectedKeys(): void { - $slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', false, 10); + $slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30, 8, false, null, 10); $arr = $slot->toArray(); - self::assertArrayHasKey('id', $arr); - self::assertArrayHasKey('instructor_id', $arr); - self::assertArrayHasKey('start_dt', $arr); - self::assertArrayHasKey('end_dt', $arr); - self::assertArrayHasKey('is_booked', $arr); - } - - public function testDefaultIsBookedIsFalse(): void - { - $slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00'); - self::assertFalse($slot->isBooked); - } - - public function testDefaultIdIsNull(): void - { - $slot = new AvailabilitySlot(1, '2026-04-01 09:00:00', '2026-04-01 10:00:00'); - self::assertNull($slot->id); + foreach (['id', 'instructor_id', 'offering_id', 'start_dt', 'end_dt', 'duration_minutes', 'is_booked', 'recurrence_group'] as $key) { + self::assertArrayHasKey($key, $arr); + } } } diff --git a/tests/Unit/Offering/OfferingRepositoryTest.php b/tests/Unit/Offering/OfferingRepositoryTest.php index 86f88dc..cf0a18b 100644 --- a/tests/Unit/Offering/OfferingRepositoryTest.php +++ b/tests/Unit/Offering/OfferingRepositoryTest.php @@ -35,7 +35,7 @@ class OfferingRepositoryTest extends TestCase return $data['instructor_id'] === 5 && $data['kind'] === Offering::KIND_PRIVATE_LESSON && $data['title'] === '30-min Piano' - && $data['price_cents'] === 3500 + && $data['price'] === 35.00 && $data['allow_weekly'] === 0 && $data['is_active'] === 1 && $data['created_at'] === '2026-04-01 12:00:00'; @@ -49,7 +49,7 @@ class OfferingRepositoryTest extends TestCase instructorId: 5, kind: Offering::KIND_PRIVATE_LESSON, title: '30-min Piano', - priceCents: 3500, + price: 35.00, durationMinutes: 30, ); @@ -168,7 +168,7 @@ class OfferingRepositoryTest extends TestCase 'title' => '30-min Piano', 'description' => null, 'duration_minutes' => '30', - 'price_cents' => '3500', + 'price' => '35.00', 'currency' => 'CAD', 'billing_mode' => Offering::BILLING_ONE_TIME, 'allow_weekly' => '0', diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index fc27e42..c90bfb2 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -14,7 +14,7 @@ class OfferingTest extends TestCase instructorId: 5, kind: Offering::KIND_PRIVATE_LESSON, title: '30-min Piano', - priceCents: 3500, + price: 35.00, billingMode: Offering::BILLING_ONE_TIME, durationMinutes: 30, id: 42, @@ -23,7 +23,7 @@ class OfferingTest extends TestCase self::assertSame(5, $offering->instructorId); self::assertSame(Offering::KIND_PRIVATE_LESSON, $offering->kind); self::assertSame('30-min Piano', $offering->title); - self::assertSame(3500, $offering->priceCents); + self::assertSame(35.00, $offering->price); self::assertSame(30, $offering->durationMinutes); self::assertSame(42, $offering->id); } @@ -32,7 +32,7 @@ class OfferingTest extends TestCase { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); - self::assertSame(0, $offering->priceCents); + self::assertSame(0.0, $offering->price); self::assertSame('CAD', $offering->currency); self::assertSame(Offering::BILLING_ONE_TIME, $offering->billingMode); self::assertNull($offering->durationMinutes); @@ -51,7 +51,7 @@ class OfferingTest extends TestCase 'title' => 'Year Choir', 'description' => 'Weekly choir', 'duration_minutes' => null, - 'price_cents' => '12000', + 'price' => '120.00', 'currency' => 'CAD', 'billing_mode' => Offering::BILLING_FULL_TERM, 'allow_weekly' => '0', @@ -67,7 +67,7 @@ class OfferingTest extends TestCase self::assertSame(7, $offering->id); self::assertSame(3, $offering->instructorId); self::assertNull($offering->durationMinutes); - self::assertSame(12000, $offering->priceCents); + self::assertSame(120.00, $offering->price); self::assertSame(20, $offering->capacity); self::assertSame(Offering::BILLING_FULL_TERM, $offering->billingMode); self::assertTrue($offering->isActive); @@ -78,7 +78,7 @@ class OfferingTest extends TestCase $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10); $arr = $offering->toArray(); - foreach (['id', 'instructor_id', 'kind', 'title', 'price_cents', 'billing_mode', 'is_active'] as $key) { + foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'is_active'] as $key) { self::assertArrayHasKey($key, $arr); } } -- 2.52.0