Skip to content

Commit c71ba0a

Browse files
committed
Merge branch 'release/0.8.72'
2 parents ff4d972 + 5f1a1e9 commit c71ba0a

38 files changed

Lines changed: 3259 additions & 135 deletions

.version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"strategy": "semver",
33
"major": 0,
44
"minor": 8,
5-
"patch": 71,
5+
"patch": 72,
66
"build": 0
77
}

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"neuron-php/jobs": "0.2.*",
2020
"neuron-php/orm": "0.1.*",
2121
"neuron-php/dto": "0.0.*",
22+
"rlanvin/php-rrule": "^2.6",
2223
"phpmailer/phpmailer": "^6.9",
2324
"cloudinary/cloudinary_php": "^2.0",
2425
"php-di/php-di": "^7.1",

resources/.cms-manifest.json

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,31 +21,40 @@
2121
"20260609120000_add_registration_to_events.php",
2222
"20260609120001_create_event_registrations_table.php",
2323
"20260609130000_add_capacity_to_events.php",
24-
"20260618120000_add_external_url_to_events.php"
24+
"20260618120000_add_external_url_to_events.php",
25+
"20260619140000_add_recurrence_to_events.php",
26+
"20260619140001_create_event_recurrence_exceptions_table.php",
27+
"20260619140002_add_occurrence_date_to_event_registrations.php"
2528
],
2629
"config_files": [
2730
"auth.yaml",
2831
"event-listeners.yaml",
2932
"neuron.yaml",
3033
"neuron.yaml.example",
31-
"routes.yaml",
32-
"schedule.yaml"
34+
"routing.yaml",
35+
"schedule.yaml",
36+
"services.yaml"
3337
],
3438
"view_directories": [
3539
"admin",
3640
"auth",
3741
"blog",
42+
"calendar",
3843
"contact",
3944
"content",
45+
"email",
4046
"emails",
4147
"home",
4248
"http_codes",
4349
"layouts",
44-
"member"
50+
"member",
51+
"pages",
52+
"partials"
4553
],
4654
"public_assets": [
47-
"index.php",
48-
".htaccess"
55+
".htaccess",
56+
"icon.png",
57+
"index.php"
4958
],
5059
"breaking_changes": [],
5160
"deprecations": [],

resources/config/services.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ services:
269269
Neuron\Cms\Services\Event\Updater:
270270
type: autowire
271271

272+
Neuron\Cms\Services\Event\RecurrenceEditor:
273+
type: autowire
274+
275+
Neuron\Cms\Services\Event\RecurrenceExpander:
276+
type: autowire
277+
272278

273279
# ============================================================================
274280
# EVENT CATEGORY CRUD SERVICES
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
use Phinx\Migration\AbstractMigration;
4+
5+
/**
6+
* Add recurrence (RFC 5545 RRULE) support to the events table.
7+
*
8+
* A recurring event stores its rule in `rrule` (the master). Individual
9+
* occurrences are expanded on the fly at query time. A modified single
10+
* occurrence is stored as a child "override" row that points back to its
11+
* master via `recurrence_parent_id` and identifies the original occurrence it
12+
* replaces via `recurrence_id`. `recurrence_until` caches the last occurrence
13+
* date of a bounded rule (NULL for an infinite rule) so range queries can skip
14+
* masters whose window does not intersect the requested range.
15+
*
16+
* NOTE: For EXISTING installations that already ran the original
17+
* create_events_table migration. New installations pick up the columns from
18+
* this migration when running migrations in order.
19+
*/
20+
class AddRecurrenceToEvents extends AbstractMigration
21+
{
22+
/**
23+
* Add recurrence columns to events table
24+
*/
25+
public function change()
26+
{
27+
$table = $this->table( 'events' );
28+
29+
if( !$table->hasColumn( 'rrule' ) )
30+
{
31+
$table->addColumn( 'rrule', 'text', [
32+
'null' => true,
33+
'after' => 'all_day'
34+
] )->update();
35+
}
36+
37+
if( !$table->hasColumn( 'recurrence_parent_id' ) )
38+
{
39+
$table->addColumn( 'recurrence_parent_id', 'integer', [
40+
'signed' => false,
41+
'null' => true,
42+
'after' => 'rrule'
43+
] )
44+
->addIndex( [ 'recurrence_parent_id' ] )
45+
->addForeignKey( 'recurrence_parent_id', 'events', 'id', [
46+
'delete' => 'CASCADE',
47+
'update' => 'CASCADE'
48+
] )
49+
->update();
50+
}
51+
52+
if( !$table->hasColumn( 'recurrence_id' ) )
53+
{
54+
$table->addColumn( 'recurrence_id', 'datetime', [
55+
'null' => true,
56+
'after' => 'recurrence_parent_id'
57+
] )
58+
->addIndex( [ 'recurrence_id' ] )
59+
->update();
60+
}
61+
62+
if( !$table->hasColumn( 'recurrence_until' ) )
63+
{
64+
$table->addColumn( 'recurrence_until', 'datetime', [
65+
'null' => true,
66+
'after' => 'recurrence_id'
67+
] )
68+
->addIndex( [ 'recurrence_until' ] )
69+
->update();
70+
}
71+
}
72+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Phinx\Migration\AbstractMigration;
4+
5+
/**
6+
* Create event_recurrence_exceptions table.
7+
*
8+
* Stores excluded occurrence dates (EXDATE) for a recurring master event.
9+
* A cancelled single occurrence is recorded here; when expanding the master
10+
* rule any occurrence whose start matches an exception row is skipped.
11+
*/
12+
class CreateEventRecurrenceExceptionsTable extends AbstractMigration
13+
{
14+
/**
15+
* Create event_recurrence_exceptions table
16+
*/
17+
public function change()
18+
{
19+
$table = $this->table( 'event_recurrence_exceptions', [ 'signed' => false ] );
20+
21+
$table->addColumn( 'event_id', 'integer', [ 'signed' => false, 'null' => false ] )
22+
->addColumn( 'occurrence_date', 'datetime', [ 'null' => false ] )
23+
->addColumn( 'created_at', 'timestamp', [ 'default' => 'CURRENT_TIMESTAMP' ] )
24+
->addIndex( [ 'event_id', 'occurrence_date' ], [ 'unique' => true ] )
25+
->addForeignKey( 'event_id', 'events', 'id', [ 'delete' => 'CASCADE', 'update' => 'CASCADE' ] )
26+
->create();
27+
}
28+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
use Phinx\Migration\AbstractMigration;
4+
5+
/**
6+
* Add occurrence_date to event_registrations.
7+
*
8+
* For recurring events, a registration targets a specific occurrence so that
9+
* capacity and duplicate-email checks are scoped per occurrence. For
10+
* non-recurring events the column is NULL and behaviour is unchanged.
11+
*
12+
* NOTE: For EXISTING installations that already ran the
13+
* create_event_registrations_table migration. New installations pick up the
14+
* column from this migration when running migrations in order.
15+
*/
16+
class AddOccurrenceDateToEventRegistrations extends AbstractMigration
17+
{
18+
/**
19+
* Add occurrence_date column to event_registrations table
20+
*/
21+
public function change()
22+
{
23+
$table = $this->table( 'event_registrations' );
24+
25+
if( !$table->hasColumn( 'occurrence_date' ) )
26+
{
27+
$table->addColumn( 'occurrence_date', 'datetime', [
28+
'null' => true,
29+
'after' => 'event_id'
30+
] )
31+
->addIndex( [ 'event_id', 'occurrence_date' ] )
32+
->update();
33+
}
34+
}
35+
}

resources/views/admin/events/create.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,61 @@
6464
</div>
6565
</div>
6666

67+
<fieldset class="border rounded p-3 mb-3">
68+
<legend class="float-none w-auto px-2 fs-6 text-muted">Repeat</legend>
69+
70+
<div class="mb-3">
71+
<label for="repeat_freq" class="form-label">Repeats</label>
72+
<select class="form-select" id="repeat_freq" name="repeat_freq" data-recurrence-freq>
73+
<option value="none" selected>Does not repeat</option>
74+
<option value="daily">Daily</option>
75+
<option value="weekly">Weekly</option>
76+
<option value="monthly">Monthly</option>
77+
<option value="yearly">Yearly</option>
78+
</select>
79+
</div>
80+
81+
<div data-recurrence-options style="display: none;">
82+
<div class="mb-3">
83+
<label for="repeat_interval" class="form-label">Every</label>
84+
<div class="input-group">
85+
<input type="number" class="form-control" id="repeat_interval" name="repeat_interval" min="1" value="1">
86+
<span class="input-group-text" data-recurrence-unit>day(s)</span>
87+
</div>
88+
</div>
89+
90+
<div class="mb-3" data-recurrence-byday-group style="display: none;">
91+
<label class="form-label d-block">Repeat on</label>
92+
<div class="btn-group flex-wrap" role="group" aria-label="Weekdays">
93+
<?php foreach( [ 'MO' => 'Mon', 'TU' => 'Tue', 'WE' => 'Wed', 'TH' => 'Thu', 'FR' => 'Fri', 'SA' => 'Sat', 'SU' => 'Sun' ] as $code => $label ): ?>
94+
<input type="checkbox" class="btn-check" id="byday_<?= $code ?>" value="<?= $code ?>" data-recurrence-byday autocomplete="off">
95+
<label class="btn btn-outline-secondary btn-sm" for="byday_<?= $code ?>"><?= $label ?></label>
96+
<?php endforeach; ?>
97+
</div>
98+
<input type="hidden" name="repeat_byday" id="repeat_byday" value="">
99+
</div>
100+
101+
<div class="mb-3">
102+
<label for="repeat_end" class="form-label">Ends</label>
103+
<select class="form-select" id="repeat_end" name="repeat_end" data-recurrence-end>
104+
<option value="never" selected>Never</option>
105+
<option value="until">On date</option>
106+
<option value="count">After number of occurrences</option>
107+
</select>
108+
</div>
109+
110+
<div class="mb-3" data-recurrence-until-group style="display: none;">
111+
<label for="repeat_until" class="form-label">End date</label>
112+
<input type="date" class="form-control" id="repeat_until" name="repeat_until">
113+
</div>
114+
115+
<div class="mb-3" data-recurrence-count-group style="display: none;">
116+
<label for="repeat_count" class="form-label">Number of occurrences</label>
117+
<input type="number" class="form-control" id="repeat_count" name="repeat_count" min="1">
118+
</div>
119+
</div>
120+
</fieldset>
121+
67122
<div class="mb-3">
68123
<label for="location" class="form-label">Location</label>
69124
<input type="text" class="form-control" id="location" name="location" placeholder="e.g., Main Auditorium, 123 Main St, Sarasota, FL">
@@ -384,3 +439,48 @@ function generateSlug() {
384439
}
385440
});
386441
</script>
442+
443+
<script>
444+
// Recurrence controls: toggle option visibility and sync BYDAY selection.
445+
(function() {
446+
var freq = document.querySelector('[data-recurrence-freq]');
447+
if( !freq ) { return; }
448+
449+
var options = document.querySelector('[data-recurrence-options]');
450+
var bydayGroup = document.querySelector('[data-recurrence-byday-group]');
451+
var unit = document.querySelector('[data-recurrence-unit]');
452+
var endSelect = document.querySelector('[data-recurrence-end]');
453+
var untilGroup = document.querySelector('[data-recurrence-until-group]');
454+
var countGroup = document.querySelector('[data-recurrence-count-group]');
455+
var bydayInput = document.getElementById('repeat_byday');
456+
var bydayChecks = document.querySelectorAll('[data-recurrence-byday]');
457+
458+
var units = { daily: 'day(s)', weekly: 'week(s)', monthly: 'month(s)', yearly: 'year(s)' };
459+
460+
function syncByday() {
461+
if( !bydayInput ) { return; }
462+
var selected = [];
463+
bydayChecks.forEach(function(box) { if( box.checked ) { selected.push(box.value); } });
464+
bydayInput.value = selected.join(',');
465+
}
466+
467+
function refresh() {
468+
var value = freq.value;
469+
var repeats = value !== 'none';
470+
if( options ) { options.style.display = repeats ? 'block' : 'none'; }
471+
if( bydayGroup ) { bydayGroup.style.display = value === 'weekly' ? 'block' : 'none'; }
472+
if( unit && units[value] ) { unit.textContent = units[value]; }
473+
if( endSelect ) {
474+
if( untilGroup ) { untilGroup.style.display = endSelect.value === 'until' ? 'block' : 'none'; }
475+
if( countGroup ) { countGroup.style.display = endSelect.value === 'count' ? 'block' : 'none'; }
476+
}
477+
}
478+
479+
freq.addEventListener('change', refresh);
480+
if( endSelect ) { endSelect.addEventListener('change', refresh); }
481+
bydayChecks.forEach(function(box) { box.addEventListener('change', syncByday); });
482+
483+
refresh();
484+
syncByday();
485+
})();
486+
</script>

0 commit comments

Comments
 (0)