| @@ -9,39 +9,23 @@ | ||
| 9 | 9 | |
| 10 | 10 | /** |
| 11 | 11 | * Thin wrapper over the slot engine for the MCP tools. |
| 12 | 12 | * |
| 13 | - * Deliberately thin. Availability is the one answer an agent must never get | |
| 14 | - * differently from what a visitor sees on the booking page, so this class | |
| 15 | - * computes nothing: it resolves the same service the public page resolves | |
| 16 | - * (TimeSlotServiceHandler::initService, which returns the round-robin / | |
| 17 | - * collective / one-off / multi variants for Pro event types), calls the same | |
| 18 | - * method, and then only reshapes and trims the result. | |
| 19 | - * | |
| 20 | - * The reshaping is the point. getAvailableSpots() returns one array per slot, | |
| 21 | - * keyed by full timestamp — a 30-day window on a 30-minute event is ~480 of | |
| 22 | - * those, which is roughly 12k tokens of an agent's context for a single call. | |
| 23 | - * Keyed by date with bare "HH:MM" strings, the same information is about a | |
| 24 | - * tenth of that. See docs/mcp-server-spec.md §10. | |
| 13 | + * An agent must see the same availability as the booking page, so this | |
| 14 | + * computes nothing. It calls the same service the public page uses | |
| 15 | + * (TimeSlotServiceHandler::initService) and only reshapes and trims the | |
| 16 | + * result: slots keyed by date as "HH:MM" strings cost about a tenth of the | |
| 17 | + * engine's per-slot arrays in tokens. See docs/mcp-server-spec.md §10. | |
| 25 | 18 | */ |
| 26 | 19 | class SlotResolver |
| 27 | 20 | { |
| 28 | - /** | |
| 29 | - * Hard ceiling on a slot query, in days. A caller asking for a year of | |
| 30 | - * availability does not want a year of availability in one response; it | |
| 31 | - * wants a smaller question it has not thought of yet. | |
| 32 | - */ | |
| 21 | + // Hard ceiling on a slot query, in days. | |
| 33 | 22 | const MAX_RANGE_DAYS = 62; |
| 34 | 23 | |
| 35 | 24 | /** |
| 36 | - * Hard ceiling on slots in one response, enforced at a whole-date boundary. | |
| 37 | - * | |
| 38 | - * A 62-day window on a busy event is ~1,400 slots ≈ 3,500 tokens, which | |
| 39 | - * overruns the budget in docs/mcp-server-spec.md §10 by more than double. | |
| 40 | - * Measured at ~8.6 bytes per slot, 600 keeps a full response near 1,500 | |
| 41 | - * tokens. The cap is never silent: the response says it truncated and names | |
| 42 | - * the first date it left out, so the agent asks for the next window instead | |
| 43 | - * of concluding the calendar ends there. | |
| 25 | + * Hard ceiling on slots in one response, applied at a whole-date boundary. | |
| 26 | + * At ~8.6 bytes per slot, 600 keeps a response near 1,500 tokens (the | |
| 27 | + * budget in docs/mcp-server-spec.md §10). Truncation is always reported. | |
| 44 | 28 | */ |
| 45 | 29 | const MAX_SLOTS = 600; |
| 46 | 30 | |
| 47 | 31 | /** |
| @@ -57,12 +41,10 @@ | ||
| 57 | 41 | */ |
| 58 | 42 | public static function getSlots(CalendarSlot $event, $from, $to, $timezone, $duration = null, $hostId = null) |
| 59 | 43 | { |
| 60 | 44 | if ($event->status !== 'active') { |
| 61 | - // The slot engine does not check event status — BookingController | |
| 62 | - // does, before it ever calls the engine. Without mirroring that here | |
| 63 | - // a draft event reports a full calendar of bookable times that the | |
| 64 | - // public page would refuse, and an agent would try to book into it. | |
| 45 | + // The slot engine doesn't check status (BookingController does), so | |
| 46 | + // a draft event would otherwise show slots the public page refuses. | |
| 65 | 47 | return [ |
| 66 | 48 | 'slots' => [], |
| 67 | 49 | 'reason' => sprintf( |
| 68 | 50 | /* translators: %s: the event type's current status */ |
| @@ -81,14 +63,10 @@ | ||
| 81 | 63 | $slots = []; |
| 82 | 64 | $spotsRemaining = []; |
| 83 | 65 | $lastError = null; |
| 84 | 66 | |
| 85 | - // The engine is month-bounded: getAvailableSpots() derives its end date | |
| 86 | - // via getMaxBookableDateTime(), which clamps to the last day of the | |
| 87 | - // START date's month, because the booking page renders one month at a | |
| 88 | - // time. A single call for 23 Aug – 5 Sep therefore returns August only | |
| 89 | - // and reports nothing for September — which an agent reads as "fully | |
| 90 | - // booked" rather than "not asked". So walk the range a month at a time | |
| 67 | + // getAvailableSpots() only returns the start date's month (the booking | |
| 68 | + // page renders one month at a time), so walk the range month by month | |
| 91 | 69 | // and merge. MAX_RANGE_DAYS keeps this to at most three calls. |
| 92 | 70 | $cursor = $from; |
| 93 | 71 | |
| 94 | 72 | while ($cursor <= $to) { |
| @@ -94,11 +72,10 @@ | ||
| 94 | 72 | while ($cursor <= $to) { |
| 95 | 73 | $spots = $service->getAvailableSpots($cursor . ' 00:00:00', $timezone, $duration, $hostId); |
| 96 | 74 | |
| 97 | 75 | if (is_wp_error($spots)) { |
| 98 | - // One month being unusable (its window is past the event's | |
| 99 | - // bookable range) says nothing about the others — keep going and | |
| 100 | - // only surface the error if no month yields anything. | |
| 76 | + // A month past the bookable range says nothing about the others. | |
| 77 | + // Only surface the error if no month yields anything. | |
| 101 | 78 | $lastError = $spots; |
| 102 | 79 | } else { |
| 103 | 80 | self::mergeMonth((array) $spots, $from, $to, $slots, $spotsRemaining); |
| 104 | 81 | } |
| @@ -104,10 +81,9 @@ | ||
| 104 | 81 | } |
| 105 | 82 | |
| 106 | 83 | $next = gmdate('Y-m-01', strtotime(gmdate('Y-m-01', strtotime($cursor)) . ' +1 month')); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date |
| 107 | 84 | |
| 108 | - // Guard against a non-advancing cursor: an infinite loop inside a | |
| 109 | - // request is worse than a wrong answer. | |
| 85 | + // Guard against a non-advancing cursor. | |
| 110 | 86 | if ($next <= $cursor) { |
| 111 | 87 | break; |
| 112 | 88 | } |
| 113 | 89 | |
| @@ -126,13 +102,11 @@ | ||
| 126 | 102 | return self::truncate($slots, $spotsRemaining); |
| 127 | 103 | } |
| 128 | 104 | |
| 129 | 105 | /** |
| 130 | - * Apply MAX_SLOTS at a whole-date boundary and say so when it bites. | |
| 106 | + * Apply MAX_SLOTS at a whole-date boundary, since half a day would read | |
| 107 | + * as a half-booked day. | |
| 131 | 108 | * |
| 132 | - * Whole dates rather than a flat slot count: half a Tuesday reads as a | |
| 133 | - * Tuesday that is half booked, which is a different and wrong answer. | |
| 134 | - * | |
| 135 | 109 | * @param array $slots |
| 136 | 110 | * @param array $spotsRemaining |
| 137 | 111 | * @return array |
| 138 | 112 | */ |
| @@ -172,14 +146,11 @@ | ||
| 172 | 146 | return $result; |
| 173 | 147 | } |
| 174 | 148 | |
| 175 | 149 | /** |
| 176 | - * Fold one month's raw engine output into the accumulating result, trimmed | |
| 177 | - * to the requested window. | |
| 150 | + * Fold one month's engine output into the result, trimmed to the window. | |
| 151 | + * The engine can snap its start back to the 1st, so both bounds are trimmed. | |
| 178 | 152 | * |
| 179 | - * getAvailableSpots() can also snap its start back to the first of the month, | |
| 180 | - * so the lower bound needs trimming as well as the upper. | |
| 181 | - * | |
| 182 | 153 | * @param array $spots raw engine output |
| 183 | 154 | * @param string $from |
| 184 | 155 | * @param string $to |
| 185 | 156 | * @param array $slots accumulator, by reference |
| @@ -206,12 +177,10 @@ | ||
| 206 | 177 | if (!in_array($time, $times, true)) { |
| 207 | 178 | $times[] = $time; |
| 208 | 179 | } |
| 209 | 180 | |
| 210 | - // `remaining` is false on every event that does not track spots, | |
| 211 | - // which is most of them. Only build the parallel map when there | |
| 212 | - // is something in it — an always-present map of nulls is pure | |
| 213 | - // context cost. | |
| 181 | + // `remaining` is false on events that don't track spots (most), | |
| 182 | + // so only build the map when there is something to put in it. | |
| 214 | 183 | if (isset($slot['remaining']) && $slot['remaining'] !== false && $slot['remaining'] !== null) { |
| 215 | 184 | $spotsRemaining[$date][$time] = (int) $slot['remaining']; |
| 216 | 185 | } |
| 217 | 186 | } |
| @@ -225,11 +194,10 @@ | ||
| 225 | 194 | |
| 226 | 195 | /** |
| 227 | 196 | * Is one specific slot bookable right now? |
| 228 | 197 | * |
| 229 | - * Runs the same engine as getSlots() rather than scanning its output, so the | |
| 230 | - * answer reflects the state at the moment of asking — this is the check a | |
| 231 | - * write path relies on, and a cached list is exactly what it must not trust. | |
| 198 | + * Asks the engine directly rather than scanning getSlots() output, because | |
| 199 | + * write paths rely on this and need the current state. | |
| 232 | 200 | * |
| 233 | 201 | * @param CalendarSlot $event |
| 234 | 202 | * @param string $startUtc 'Y-m-d H:i:s' in UTC, already validated by |
| 235 | 203 | * MCPHelper::toUtc() |
| @@ -296,12 +264,10 @@ | ||
| 296 | 264 | * @return array|\WP_Error [$from, $to] |
| 297 | 265 | */ |
| 298 | 266 | public static function resolveRange($from, $to) |
| 299 | 267 | { |
| 300 | - // Absent and unparseable are different questions. Both used to | |
| 301 | - // normalise to '', so `from: "next tuesday"` fell through to the | |
| 302 | - // default window and came back as a confident answer about the wrong | |
| 303 | - // fortnight. Matches BookingTools::dateRange(). | |
| 268 | + // An unparseable date is an error, not a request for the default | |
| 269 | + // window. Matches BookingTools::dateRange(). | |
| 304 | 270 | foreach (['from' => $from, 'to' => $to] as $key => $value) { |
| 305 | 271 | if (self::suppliedDate($value) && !self::normalizeDate($value)) { |
| 306 | 272 | return MCPHelper::error( |
| 307 | 273 | 'invalid_date', |
| @@ -366,10 +332,8 @@ | ||
| 366 | 332 | if (!$date) { |
| 367 | 333 | return ''; |
| 368 | 334 | } |
| 369 | 335 | |
| 370 | - // Y-m-d only. strtotime() would also accept "next tuesday", "+1 year" | |
| 371 | - // and "5", resolving them against the current instant and answering a | |
| 372 | - // question nobody asked. | |
| 336 | + // Y-m-d only. strtotime() would accept "next tuesday" or "5". | |
| 373 | 337 | return MCPHelper::isRealDate($date) ? $date : ''; |
| 374 | 338 | } |
| 375 | 339 | } |