From 33a8f7f7e29057d2f2a8b7fdb98e4e5c5ae17f70 Mon Sep 17 00:00:00 2001 From: olaservo Date: Thu, 18 Jun 2026 07:05:08 -0700 Subject: [PATCH 1/3] Add "every N weeks" recurrence cadence Adds a flexible "Every N weeks on [Day]" recurring-event option alongside the existing weekly/biweekly/monthly/yearly presets, so templates can repeat at any week interval (e.g. every 3 weeks). - Migration: add nullable recurrence_interval_count to events + event_revisions - Event model: new weekly_n case in recurrence_description / recurrence_date_interval (P{n}W) / recurrence_end_datetime (n*5 week lookahead); register recurrence_interval_count as editable - EventController: persist + validate the count (required_if weekly_n) in create_event and save_event - Views: dropdown option + revealed number input, with JS toggle/restore - Tests: unit coverage for interval, lookahead window, and weekly fallback Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Event.php | 8 +++- app/Http/Controllers/EventController.php | 4 ++ ...06_10_000000_recurrence_interval_count.php | 38 +++++++++++++++++++ resources/views/edit-event.blade.php | 17 +++++++++ .../views/recurring-event-details.blade.php | 8 ++++ tests/Unit/EventTest.php | 31 +++++++++++++++ 6 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_06_10_000000_recurrence_interval_count.php diff --git a/app/Event.php b/app/Event.php index 5b7c586..22298fb 100644 --- a/app/Event.php +++ b/app/Event.php @@ -34,7 +34,7 @@ class Event extends Model 'latitude', 'longitude', 'timezone', 'status', 'website', 'tickets_url', 'code_of_conduct_url', 'meeting_url', 'video_url', 'notes_url', 'summary', 'description', 'cover_image', 'unlisted', 'parent_id', 'hide_from_main_feed', - 'recurrence_interval', + 'recurrence_interval', 'recurrence_interval_count', ]; public static function slug_from_name($name) { @@ -424,6 +424,8 @@ public function recurrence_description() { return 'Every week on '.$start->format('l').'s'; case 'biweekly_dow': return 'Every other week on '.$start->format('l').'s'; + case 'weekly_n': + return 'Every '.$this->recurrence_interval_count.' weeks on '.$start->format('l').'s'; case 'monthly_date': return 'Every month on the '.$start->format('dS'); case 'yearly': @@ -440,6 +442,8 @@ public function recurrence_date_interval() { return new DateInterval('P1W'); case 'biweekly_dow': return new DateInterval('P2W'); + case 'weekly_n': + return new DateInterval('P'.((int)$this->recurrence_interval_count ?: 1).'W'); case 'monthly_date': return new DateInterval('P1M'); case 'yearly': @@ -458,6 +462,8 @@ public function recurrence_end_datetime() { return $now->add(new DateInterval('P5W')); case 'biweekly_dow': return $now->add(new DateInterval('P9W')); + case 'weekly_n': + return $now->add(new DateInterval('P'.(((int)$this->recurrence_interval_count ?: 1) * 5).'W')); case 'monthly_date': return $now->add(new DateInterval('P4M')); case 'yearly': diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index c2a33bd..180d839 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -65,6 +65,7 @@ public function create_event(Request $request) { 'name' => 'required', 'start_date' => 'required|date_format:Y-m-d', 'status' => 'in:'.implode(',', array_keys(Event::$STATUSES)), + 'recurrence_interval_count' => 'required_if:recurrence_interval,weekly_n|nullable|integer|min:1|max:52', ]); $event = new Event(); @@ -75,6 +76,7 @@ public function create_event(Request $request) { if(request('is_template')) { $event->is_template = true; $event->recurrence_interval = request('recurrence_interval'); + $event->recurrence_interval_count = request('recurrence_interval_count') ?: null; $event->key = ''; } @@ -244,6 +246,7 @@ public function save_event(Request $request, Event $event) { 'name' => 'required', 'start_date' => 'required|date_format:Y-m-d', 'status' => 'in:'.implode(',', array_keys(Event::$STATUSES)), + 'recurrence_interval_count' => 'required_if:recurrence_interval,weekly_n|nullable|integer|min:1|max:52', ]); if($event->fields_from_ics) { @@ -312,6 +315,7 @@ public function save_event(Request $request, Event $event) { // Allow event templates to change the recurrence property if($event->is_template) { $event->recurrence_interval = request('recurrence_interval'); + $event->recurrence_interval_count = request('recurrence_interval_count') ?: null; } $event->last_modified_by = Auth::user()->id; diff --git a/database/migrations/2026_06_10_000000_recurrence_interval_count.php b/database/migrations/2026_06_10_000000_recurrence_interval_count.php new file mode 100644 index 0000000..4a88cee --- /dev/null +++ b/database/migrations/2026_06_10_000000_recurrence_interval_count.php @@ -0,0 +1,38 @@ +unsignedInteger('recurrence_interval_count')->nullable(); + }); + Schema::table('event_revisions', function (Blueprint $table) { + $table->unsignedInteger('recurrence_interval_count')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn('recurrence_interval_count'); + }); + Schema::table('event_revisions', function (Blueprint $table) { + $table->dropColumn('recurrence_interval_count'); + }); + } +}; diff --git a/resources/views/edit-event.blade.php b/resources/views/edit-event.blade.php index 12f446c..f554989 100644 --- a/resources/views/edit-event.blade.php +++ b/resources/views/edit-event.blade.php @@ -246,6 +246,13 @@