Skip to content

Commit 78dd93c

Browse files
feat: monthly ordinal weekday recurrence (1st/last Monday, 2nd Friday, etc.)
- Add first/second/third/fourth/last {Weekday}OfMonth() to ScheduleBuilder - Add MonthlyOrdinalWeekdayFrequencyConfig for monthly_ordinal_weekday - Extend SafeFrequencyConfigCast and Model ScheduleBuilder forDate - Document in README and Boost skills (zap-recurrence, zap-schedules) - Reorganize README with TOC and clearer recurrence/query sections - Add unit and feature tests (no regression) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4862572 commit 78dd93c

11 files changed

Lines changed: 769 additions & 335 deletions

File tree

README.md

Lines changed: 138 additions & 328 deletions
Large diffs are not rendered by default.

resources/boost/skills/zap-recurrence/SKILL.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This skill covers all recurrence patterns available in Laravel Zap for creating
2121
| `everyFiveWeeks` ... `everyFiftyTwoWeeks` | Every N weeks (3-52) |
2222
| `everyFourMonths` | Every four months |
2323
| `everyFiveMonths` ... `everyElevenMonths` | Every N months (4, 5, 7-11) |
24+
| `monthly_ordinal_weekday` | First, second, third, fourth, or last weekday of each month (e.g. `firstWednesdayOfMonth`, `lastMondayOfMonth`) |
2425

2526
## Daily Recurrence
2627

@@ -142,6 +143,41 @@ Zap::for($resource)
142143
->save();
143144
```
144145

146+
## Monthly Ordinal Weekday (first / second / third / fourth / last X of month)
147+
148+
Recur on the 1st, 2nd, 3rd, 4th, or **last** occurrence of a given weekday each month. Use method names: `first{Day}OfMonth`, `second{Day}OfMonth`, `third{Day}OfMonth`, `fourth{Day}OfMonth`, `last{Day}OfMonth` with any day name (Sunday through Saturday).
149+
150+
```php
151+
// Every 1st Wednesday of the month
152+
Zap::for($resource)
153+
->named('Monthly Standup')
154+
->appointment()
155+
->firstWednesdayOfMonth()
156+
->forYear(2025)
157+
->addPeriod('09:00', '10:00')
158+
->save();
159+
160+
// Every 2nd Friday of the month
161+
Zap::for($resource)
162+
->named('Bi-Monthly Review')
163+
->appointment()
164+
->secondFridayOfMonth()
165+
->forYear(2025)
166+
->addPeriod('14:00', '15:00')
167+
->save();
168+
169+
// Every last Monday of the month
170+
Zap::for($resource)
171+
->named('Month-End Retro')
172+
->appointment()
173+
->lastMondayOfMonth()
174+
->forYear(2025)
175+
->addPeriod('16:00', '17:00')
176+
->save();
177+
```
178+
179+
Available methods (replace `{Day}` with Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday): `first{Day}OfMonth`, `second{Day}OfMonth`, `third{Day}OfMonth`, `fourth{Day}OfMonth`, `last{Day}OfMonth`. Examples: `firstWednesdayOfMonth`, `secondFridayOfMonth`, `lastMondayOfMonth`.
180+
145181
## Bi-Monthly Recurrence
146182

147183
Every two months:
@@ -376,15 +412,24 @@ Zap::for($employee)
376412
### Monthly Review Meetings
377413

378414
```php
379-
// First Monday of each month (approximate with day 1-7)
415+
// First Monday of each month (ordinal weekday)
380416
Zap::for($room)
381417
->named('Monthly Review')
382418
->appointment()
383-
->monthly(['days_of_month' => [1]])
419+
->firstMondayOfMonth()
384420
->forYear(2025)
385421
->addPeriod('09:00', '10:00')
386422
->withMetadata(['meeting_type' => 'review'])
387423
->save();
424+
425+
// Last Friday of each month (month-end review)
426+
Zap::for($room)
427+
->named('Month-End Review')
428+
->appointment()
429+
->lastFridayOfMonth()
430+
->forYear(2025)
431+
->addPeriod('14:00', '15:00')
432+
->save();
388433
```
389434

390435
## Querying Recurring Schedules

resources/boost/skills/zap-schedules/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ Zap::for($user)
7979
->save();
8080
```
8181

82+
Recurrence is set via methods such as `daily()`, `weekly(['monday', 'friday'])`, `monthly(['days_of_month' => [1, 15]])`, and **ordinal weekday**: `firstWednesdayOfMonth()`, `secondFridayOfMonth()`, `lastMondayOfMonth()` (see zap-recurrence skill for all patterns).
83+
8284
## Date Range Methods
8385

8486
```php

src/Builders/ScheduleBuilder.php

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Zap\Data\MonthlyFrequencyConfig\BiMonthlyFrequencyConfig;
1414
use Zap\Data\MonthlyFrequencyConfig\EveryXMonthsFrequencyConfig;
1515
use Zap\Data\MonthlyFrequencyConfig\MonthlyFrequencyConfig;
16+
use Zap\Data\MonthlyFrequencyConfig\MonthlyOrdinalWeekdayFrequencyConfig;
1617
use Zap\Data\MonthlyFrequencyConfig\QuarterlyFrequencyConfig;
1718
use Zap\Data\MonthlyFrequencyConfig\SemiAnnuallyFrequencyConfig;
1819
use Zap\Data\WeeklyFrequencyConfig\BiWeeklyFrequencyConfig;
@@ -80,6 +81,41 @@
8081
* @method self everyNineMonths(array $config = [])
8182
* @method self everyTenMonths(array $config = [])
8283
* @method self everyElevenMonths(array $config = [])
84+
* @method self firstSundayOfMonth()
85+
* @method self firstMondayOfMonth()
86+
* @method self firstTuesdayOfMonth()
87+
* @method self firstWednesdayOfMonth()
88+
* @method self firstThursdayOfMonth()
89+
* @method self firstFridayOfMonth()
90+
* @method self firstSaturdayOfMonth()
91+
* @method self secondSundayOfMonth()
92+
* @method self secondMondayOfMonth()
93+
* @method self secondTuesdayOfMonth()
94+
* @method self secondWednesdayOfMonth()
95+
* @method self secondThursdayOfMonth()
96+
* @method self secondFridayOfMonth()
97+
* @method self secondSaturdayOfMonth()
98+
* @method self thirdSundayOfMonth()
99+
* @method self thirdMondayOfMonth()
100+
* @method self thirdTuesdayOfMonth()
101+
* @method self thirdWednesdayOfMonth()
102+
* @method self thirdThursdayOfMonth()
103+
* @method self thirdFridayOfMonth()
104+
* @method self thirdSaturdayOfMonth()
105+
* @method self fourthSundayOfMonth()
106+
* @method self fourthMondayOfMonth()
107+
* @method self fourthTuesdayOfMonth()
108+
* @method self fourthWednesdayOfMonth()
109+
* @method self fourthThursdayOfMonth()
110+
* @method self fourthFridayOfMonth()
111+
* @method self fourthSaturdayOfMonth()
112+
* @method self lastSundayOfMonth()
113+
* @method self lastMondayOfMonth()
114+
* @method self lastTuesdayOfMonth()
115+
* @method self lastWednesdayOfMonth()
116+
* @method self lastThursdayOfMonth()
117+
* @method self lastFridayOfMonth()
118+
* @method self lastSaturdayOfMonth()
83119
*/
84120
class ScheduleBuilder
85121
{
@@ -673,13 +709,20 @@ public function clone(): self
673709
/**
674710
* Handle dynamic method calls for everyXWeeks and everyXMonths patterns.
675711
*
676-
* @param string $method
677712
* @param array<int, mixed> $parameters
678713
*
679714
* @throws BadMethodCallException
680715
*/
681716
public function __call(string $method, array $parameters): self
682717
{
718+
// Match ordinal weekday of month: firstWednesdayOfMonth, secondFridayOfMonth, lastMondayOfMonth
719+
if (preg_match('/^(first|second|third|fourth|last)(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)OfMonth$/i', $method, $ordinalMatches)) {
720+
return $this->setMonthlyOrdinalWeekdayFrequency(
721+
$ordinalMatches[1],
722+
$ordinalMatches[2]
723+
);
724+
}
725+
683726
// Match patterns like everyThreeWeeks, everyFourMonths, etc.
684727
if (preg_match('/^every([A-Z][a-zA-Z]+)(Week|Weeks|Month|Months)$/', $method, $matches)) {
685728
$wordNumber = $matches[1];
@@ -765,8 +808,6 @@ private function wordToNumber(string $word): ?int
765808
* Set schedule to recur every X weeks.
766809
*
767810
* @param int<1, 52> $weeks
768-
* @param array $days
769-
* @param CarbonInterface|string|null $startsOn
770811
*/
771812
private function setEveryXWeeksFrequency(int $weeks, array $days, CarbonInterface|string|null $startsOn): self
772813
{
@@ -781,11 +822,48 @@ private function setEveryXWeeksFrequency(int $weeks, array $days, CarbonInterfac
781822
return $this;
782823
}
783824

825+
/**
826+
* Set schedule to recur on an ordinal weekday of each month (e.g. 1st Wednesday, last Monday).
827+
*
828+
* @param string $ordinalWord first, second, third, fourth, last
829+
* @param string $dayName Sunday, Monday, ... Saturday
830+
*/
831+
private function setMonthlyOrdinalWeekdayFrequency(string $ordinalWord, string $dayName): self
832+
{
833+
$ordinal = match (strtolower($ordinalWord)) {
834+
'first' => 1,
835+
'second' => 2,
836+
'third' => 3,
837+
'fourth' => 4,
838+
'last' => 5,
839+
default => throw new BadMethodCallException("Invalid ordinal '{$ordinalWord}' in method."),
840+
};
841+
842+
$dayOfWeek = match (strtolower($dayName)) {
843+
'sunday' => 0,
844+
'monday' => 1,
845+
'tuesday' => 2,
846+
'wednesday' => 3,
847+
'thursday' => 4,
848+
'friday' => 5,
849+
'saturday' => 6,
850+
default => 1,
851+
};
852+
853+
$this->attributes['is_recurring'] = true;
854+
$this->attributes['frequency'] = 'monthly_ordinal_weekday';
855+
$this->attributes['frequency_config'] = new MonthlyOrdinalWeekdayFrequencyConfig(
856+
ordinal: $ordinal,
857+
dayOfWeek: $dayOfWeek,
858+
);
859+
860+
return $this;
861+
}
862+
784863
/**
785864
* Set schedule to recur every X months.
786865
*
787866
* @param int<1, 12> $months
788-
* @param array $config
789867
*/
790868
private function setEveryXMonthsFrequency(int $months, array $config): self
791869
{

src/Casts/SafeFrequencyConfigCast.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Database\Eloquent\Model;
77
use Zap\Data\FrequencyConfig;
88
use Zap\Data\MonthlyFrequencyConfig\EveryXMonthsFrequencyConfig;
9+
use Zap\Data\MonthlyFrequencyConfig\MonthlyOrdinalWeekdayFrequencyConfig;
910
use Zap\Data\WeeklyFrequencyConfig\EveryXWeeksFrequencyConfig;
1011
use Zap\Models\Schedule;
1112

@@ -38,6 +39,11 @@ public function get(Model $model, string $key, mixed $value, array $attributes)
3839
);
3940
}
4041

42+
// Handle monthly ordinal weekday (e.g., "first Wednesday of month")
43+
if ($frequency === 'monthly_ordinal_weekday') {
44+
return MonthlyOrdinalWeekdayFrequencyConfig::fromArray($configArray);
45+
}
46+
4147
return $configArray;
4248
}
4349

0 commit comments

Comments
 (0)