From bfb78b19e3c0ae38f6ca2d703c825d4a58a88a8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:40:19 +0000 Subject: [PATCH 1/4] Initial plan From 2292da9d29a80d34100e8cbc49abeca3744f3887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:46:59 +0000 Subject: [PATCH 2/4] Fix issue #75: BYDAY with multiple days now returns all specified days Co-authored-by: OzzyCzech <105520+OzzyCzech@users.noreply.github.com> --- src/Freq.php | 5 ++-- src/IcalParser.php | 24 ++++++++++------ tests/cal/75_weekly_tuesday_thursday.ics | 19 +++++++++++++ tests/events.recurring.phpt | 36 ++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 tests/cal/75_weekly_tuesday_thursday.ics diff --git a/src/Freq.php b/src/Freq.php index b528a7d..924b77b 100644 --- a/src/Freq.php +++ b/src/Freq.php @@ -516,8 +516,9 @@ private function ruleByDay(string $rule, int $t): int { $_t = strtotime($s, $t); - if ($_t == $t && in_array($this->freq, ['weekly', 'monthly', 'yearly'])) { - // Yes. This is not a great idea.. but hey, it works.. for now + // For FREQ=WEEKLY with BYDAY, if today matches, return it (don't skip to next week) + // For other frequencies (monthly, yearly), skip to next occurrence + if ($_t == $t && in_array($this->freq, ['monthly', 'yearly'])) { $s = 'next ' . $d . ' ' . date('H:i:s', $t); $_t = strtotime($s, $_t); } diff --git a/src/IcalParser.php b/src/IcalParser.php index 7c43442..6855a53 100644 --- a/src/IcalParser.php +++ b/src/IcalParser.php @@ -214,21 +214,27 @@ public function parseRecurrences($event): array { // This should be fixed in the Freq class, but it's too messy to make sense of // This guard only works on WEEKLY, because the others have no fixed time interval // There may still be a bug with the others + // Skip this filter when BYDAY has multiple days (e.g., TU,TH) since events won't be exactly 7 days apart if (isset($event['RRULE']['INTERVAL']) && $recurring->getFreq() === "WEEKLY") { - $replacementList = []; + $byDay = $recurring->getByDay(); + $hasMultipleDays = $byDay !== false && is_array($byDay) && count($byDay) > 1; + + if (!$hasMultipleDays) { + $replacementList = []; - foreach($recurrenceTimestamps as $timestamp) { - $tmp = new DateTime('now', $event['DTSTART']->getTimezone()); - $tmp->setTimestamp($timestamp); + foreach($recurrenceTimestamps as $timestamp) { + $tmp = new DateTime('now', $event['DTSTART']->getTimezone()); + $tmp->setTimestamp($timestamp); - $dayCount = $event['DTSTART']->diff($tmp)->format('%a'); + $dayCount = $event['DTSTART']->diff($tmp)->format('%a'); - if ($dayCount % ($event['RRULE']['INTERVAL'] * 7) == 0) { - $replacementList[] = $timestamp; + if ($dayCount % ($event['RRULE']['INTERVAL'] * 7) == 0) { + $replacementList[] = $timestamp; + } } - } - $recurrenceTimestamps = $replacementList; + $recurrenceTimestamps = $replacementList; + } } $recurrences = []; diff --git a/tests/cal/75_weekly_tuesday_thursday.ics b/tests/cal/75_weekly_tuesday_thursday.ics new file mode 100644 index 0000000..2450700 --- /dev/null +++ b/tests/cal/75_weekly_tuesday_thursday.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DESCRIPTION:Test Event +RRULE:FREQ=WEEKLY;UNTIL=20240430T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO +UID:test-issue-75 +SUMMARY:Test Event TU,TH +DTSTART;VALUE=DATE:20240326 +DTEND;VALUE=DATE:20240327 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20240326T082436Z +TRANSP:TRANSPARENT +STATUS:CONFIRMED +SEQUENCE:0 +LOCATION: +END:VEVENT +END:VCALENDAR diff --git a/tests/events.recurring.phpt b/tests/events.recurring.phpt index 9606992..24ec02c 100644 --- a/tests/events.recurring.phpt +++ b/tests/events.recurring.phpt @@ -257,4 +257,40 @@ test('Recurring instances bi-weekly', function () { Assert::equal('31.1.2023 05:00:00', $events[0]['DTSTART']->format('j.n.Y H:i:s')); Assert::equal('14.2.2023 05:00:00', $events[1]['DTSTART']->format('j.n.Y H:i:s')); Assert::equal('28.2.2023 05:00:00', $events[2]['DTSTART']->format('j.n.Y H:i:s')); +}); + +test('Weekly recurring with Tuesday and Thursday', function () { +// https://github.com/OzzyCzech/icalparser/issues/75 + $cal = new IcalParser(); + + $cal->parseFile(__DIR__ . '/cal/75_weekly_tuesday_thursday.ics'); + $events = $cal->getEvents()->sorted(); + +// DTSTART;VALUE=DATE:20240326 (Tuesday, March 26, 2024) +// RRULE:FREQ=WEEKLY;UNTIL=20240430T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO +// Should return both Tuesdays and Thursdays: 6 Tuesdays + 5 Thursdays = 11 events + Assert::equal(11, $events->count()); + + // Week 1: Tue Mar 26, Thu Mar 28 + Assert::equal('26.3.2024 00:00:00', $events[0]['DTSTART']->format('j.n.Y H:i:s')); + Assert::equal('28.3.2024 00:00:00', $events[1]['DTSTART']->format('j.n.Y H:i:s')); + + // Week 2: Tue Apr 2, Thu Apr 4 + Assert::equal('2.4.2024 00:00:00', $events[2]['DTSTART']->format('j.n.Y H:i:s')); + Assert::equal('4.4.2024 00:00:00', $events[3]['DTSTART']->format('j.n.Y H:i:s')); + + // Week 3: Tue Apr 9, Thu Apr 11 + Assert::equal('9.4.2024 00:00:00', $events[4]['DTSTART']->format('j.n.Y H:i:s')); + Assert::equal('11.4.2024 00:00:00', $events[5]['DTSTART']->format('j.n.Y H:i:s')); + + // Week 4: Tue Apr 16, Thu Apr 18 + Assert::equal('16.4.2024 00:00:00', $events[6]['DTSTART']->format('j.n.Y H:i:s')); + Assert::equal('18.4.2024 00:00:00', $events[7]['DTSTART']->format('j.n.Y H:i:s')); + + // Week 5: Tue Apr 23, Thu Apr 25 + Assert::equal('23.4.2024 00:00:00', $events[8]['DTSTART']->format('j.n.Y H:i:s')); + Assert::equal('25.4.2024 00:00:00', $events[9]['DTSTART']->format('j.n.Y H:i:s')); + + // Week 6: Tue Apr 30 (last one before UNTIL) + Assert::equal('30.4.2024 00:00:00', $events[10]['DTSTART']->format('j.n.Y H:i:s')); }); \ No newline at end of file From 2f0da3c991195767b432e0d9a836d4cb8e993b26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:50:54 +0000 Subject: [PATCH 3/4] Address code review comments: clarify comments, fix spacing, reduce duplication Co-authored-by: OzzyCzech <105520+OzzyCzech@users.noreply.github.com> --- src/Freq.php | 2 +- src/IcalParser.php | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/Freq.php b/src/Freq.php index 924b77b..fa6d461 100644 --- a/src/Freq.php +++ b/src/Freq.php @@ -517,7 +517,7 @@ private function ruleByDay(string $rule, int $t): int { $_t = strtotime($s, $t); // For FREQ=WEEKLY with BYDAY, if today matches, return it (don't skip to next week) - // For other frequencies (monthly, yearly), skip to next occurrence + // For FREQ=MONTHLY and FREQ=YEARLY, skip to next occurrence if ($_t == $t && in_array($this->freq, ['monthly', 'yearly'])) { $s = 'next ' . $d . ' ' . date('H:i:s', $t); $_t = strtotime($s, $_t); diff --git a/src/IcalParser.php b/src/IcalParser.php index 6855a53..06cc14d 100644 --- a/src/IcalParser.php +++ b/src/IcalParser.php @@ -214,27 +214,33 @@ public function parseRecurrences($event): array { // This should be fixed in the Freq class, but it's too messy to make sense of // This guard only works on WEEKLY, because the others have no fixed time interval // There may still be a bug with the others - // Skip this filter when BYDAY has multiple days (e.g., TU,TH) since events won't be exactly 7 days apart if (isset($event['RRULE']['INTERVAL']) && $recurring->getFreq() === "WEEKLY") { $byDay = $recurring->getByDay(); $hasMultipleDays = $byDay !== false && is_array($byDay) && count($byDay) > 1; + $replacementList = []; - if (!$hasMultipleDays) { - $replacementList = []; - - foreach($recurrenceTimestamps as $timestamp) { - $tmp = new DateTime('now', $event['DTSTART']->getTimezone()); - $tmp->setTimestamp($timestamp); - - $dayCount = $event['DTSTART']->diff($tmp)->format('%a'); - + foreach ($recurrenceTimestamps as $timestamp) { + $tmp = new DateTime('now', $event['DTSTART']->getTimezone()); + $tmp->setTimestamp($timestamp); + + $dayCount = $event['DTSTART']->diff($tmp)->format('%a'); + + if ($hasMultipleDays) { + // For multiple days in BYDAY, check if the occurrence is in a valid week + // A valid week is one where the week number from DTSTART is divisible by INTERVAL + $weekCount = intdiv($dayCount, 7); + if ($weekCount % $event['RRULE']['INTERVAL'] == 0) { + $replacementList[] = $timestamp; + } + } else { + // For single day in BYDAY, the day must be exactly INTERVAL*7 days from DTSTART if ($dayCount % ($event['RRULE']['INTERVAL'] * 7) == 0) { $replacementList[] = $timestamp; } } - - $recurrenceTimestamps = $replacementList; } + + $recurrenceTimestamps = $replacementList; } $recurrences = []; From 761dfe9a8ab376df54fa2c73d698d47bac210f07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:25:29 +0000 Subject: [PATCH 4/4] Revert Freq.php changes - IcalParser.php fix alone solves issue #75 Co-authored-by: OzzyCzech <105520+OzzyCzech@users.noreply.github.com> --- src/Freq.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Freq.php b/src/Freq.php index fa6d461..b528a7d 100644 --- a/src/Freq.php +++ b/src/Freq.php @@ -516,9 +516,8 @@ private function ruleByDay(string $rule, int $t): int { $_t = strtotime($s, $t); - // For FREQ=WEEKLY with BYDAY, if today matches, return it (don't skip to next week) - // For FREQ=MONTHLY and FREQ=YEARLY, skip to next occurrence - if ($_t == $t && in_array($this->freq, ['monthly', 'yearly'])) { + if ($_t == $t && in_array($this->freq, ['weekly', 'monthly', 'yearly'])) { + // Yes. This is not a great idea.. but hey, it works.. for now $s = 'next ' . $d . ' ' . date('H:i:s', $t); $_t = strtotime($s, $_t); }