fix(calendar): keep yearly events in DTSTART's month when BYMONTH is missing - #4222
Merged
khassel merged 2 commits intoAug 18, 2026
Conversation
…missing
Several calendar clients export a yearly event as
DTSTART;VALUE=DATE:20231002
RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2
restating DTSTART's day-of-month in BYMONTHDAY but omitting BYMONTH. Since
BYMONTHDAY is an expanding rule part for FREQ=YEARLY, the recurrence expands
to the 2nd of every month, so the event shows up twelve times a year instead
of once. Google Calendar and the clients that emit this render it once a year
on DTSTART's date.
Confine such a rule to DTSTART's month, but only when it is provably a
redundant restatement of DTSTART rather than a real expansion: BYMONTHDAY must
hold exactly one value, that value must equal DTSTART's day-of-month, and no
other BYxxx part may shape the recurrence. Rules that genuinely expand, such as
FREQ=YEARLY;BYMONTHDAY=1,3 or FREQ=YEARLY;BYMONTHDAY=13;BYDAY=FR, are untouched.
Refs MagicMirrorOrg#2547, MagicMirrorOrg#3047
Collaborator
|
Thanks for the deep dive here, and for the references to the earlier discussions. I think the direction here is fine. What do you think of this: instead of reading isYearlyRuleMissingByMonth (event) {
const options = event.rrule?.origOptions;
if (!options || options.freq !== "YEARLY") {
return false;
}
const isSingleMonthDay = Array.isArray(options.byMonthDay) && options.byMonthDay.length === 1;
// Any other BYxxx part means the rule shapes the recurrence on purpose.
const hasShapingPart = Boolean(options.byMonth || options.byDay || options.byYearDay || options.byWeekNo || options.bySetPos);
if (!isSingleMonthDay || hasShapingPart) {
return false;
}
return options.byMonthDay[0] === event.start.getDate();
}, |
Collaborator
|
@pascalpfammatter Did you noticed my comment? 🙂 |
Collaborator
|
Since we didn't receive any feedback, I went ahead and made the change I suggested myself. |
khassel
approved these changes
Aug 18, 2026
Merged
KristjanESPERANTO
added a commit
that referenced
this pull request
Oct 1, 2026
## Release Notes Thanks to: @ago1776, @alkank, @Antra, @Blackspirits, @deBasMan21, @flightlesstux, @i-xul, @jamalkamaladdin, @khassel, @KristjanESPERANTO, @MannXo, @pascalpfammatter, @rejas >⚠️ This release needs nodejs version >=22.22.2 <23 || >=24 [Compare to previous Release v2.37.0](v2.37.0...develop) ### [core] - Prepare Release 2.38.0 (#4285) - fix: let Electron select the Wayland display (#4268) - fix(http): improve access error messages (#4281) - fix: always redact client configuration (#4275) - refactor: migrate browser scripts to ESM (#4272) - refactor: convert animateCSS to ES module (#4266) - use filter instead of find to get all allowed secrets when multiple instances of a module are active (#4265) - refactor: convert notificationFx to ES module (#4263) - refactor: enforce function expression style (#4262) - fix: validate client IP behind trusted proxies (#4261) - prefer arrow over function (#4252) - refactor(utils): simplify configuration validation and error handling (#4240) - fix(server): validate request origins (#4234) - remove codeql warning (#4231) - refactor(socket): use direct ESM import in main (#4225) - refactor(socket): load Socket.IO client via ESM (#4224) - style: simplify Electron startup checks (#4213) - refactor: consolidate electron bootstrap into async function (#4210) - refactor(module): simplify and fix configMerge (#4203) - refactor: convert Translator to ES module (#4202) - docs: add CodeQL review steps and restructure after-release checklist (#4200) - refactor: centralize server port resolution (#4198) - refactor: use const instead of let for variable declarations (#4196) - refactor: replace Loader object with named exports (#4195) - set next release dev number - refactor(eslint): enforce prefer-arrow-callback rule (#4251) - refactor(http_fetcher): make dynamic URL handling explicit (#4282) - refactor: use explicit global config (#4233) - expand logic of hideConfigSecrets, add more tests (#4229) - refactor: make browser startup more explicit (#4214) ### [dependencies] - update dependencies (#4276) - update dependencies incl. electron to v44 (#4250) - chore: update eslint incl. plugins + review rules (#4236) - Bump actions/stale from 10 to 11 (#4215) - update dependencies (#4221) - Bump actions/setup-node from 6 to 7 (#4204) - chore: update dependencies (#4201) - Bump electron from 42.5.2 to 43.0.0 (#4192) ### [modules/alert] - Move alert translations into common translations (#4284) ### [modules/calendar] - Fixes issue 4243: Ensure while loop in calendar module does not get stuck when no entries (#4244) - fix(calendar): escape HTML in event title and location (#4260) - calendar: allow custom events to override symbol class (#4257) - Fix calendar crash when yearmatchgroup regex does not match (#4239) - fix(calendar): keep yearly events in DTSTART's month when BYMONTH is missing (#4222) - fix(calendar): correct multi-day slice day counts (#4232) - chore: update dependencies + adapt calendar tests (#4211) - fix(calendar): sliced multi-day events start at 00:00, not 23:59 (#4208) ### [modules/newsfeed] - newsfeed: don't use cors url in njk template (#4279) - refactor(newsfeed): extract feed item normalization into feeditem.js (#4271) - refactor(newsfeed): replace feedme lib with feedparser (#4269) - newsfeed: fix allowedBasicHtmlTags (#4258) - newsfeed: update checkArticleUrl (#4256) ### [modules/updatenotification] - feat(updatenotification): implement trusted module configuration for update commands (#4259) ### [modules/weather] - feat: add FMI weather provider (#4278) - feat(weather): enable CSS ordering for forecast and hourly columns (#4118) - fix(weather): respect initial load delay (#4254) - fix(weather): restrict provider loading to the providers directory (#4235) - refactor(weather): extract WeatherProvider base class (#4228) ### [testing] - ci: fail lint on warnings (#4230) - add test if install works for minimal node version (#4197) ### [translation] - feat(i18n): add Azerbaijani translation (#4283) - feat(i18n): complete Turkish translations (#4277) - Improve Portuguese translations (#4274) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Kevin G. <crazylegstoo@gmail.com> Co-authored-by: veeck <gitkraken@veeck.de> Co-authored-by: Veeck <github@veeck.de> Co-authored-by: Karsten Hassel <hassel@gmx.de> Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com> Co-authored-by: Jboucly <contact@jboucly.fr> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com> Co-authored-by: sam detweiler <sdetweil@gmail.com> Co-authored-by: Jordan Welch <JordanHWelch@gmail.com> Co-authored-by: Blackspirits <blackspirits@gmail.com> Co-authored-by: Samed Ozdemir <samed@xsor.io> Co-authored-by: in-voker <58696565+in-voker@users.noreply.github.com> Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com> Co-authored-by: cgillinger <christian.gillinger@gmail.com> Co-authored-by: Sonny B <43247590+sonnyb9@users.noreply.github.com> Co-authored-by: sonnyb9 <sonnyb9@users.noreply.github.com> Co-authored-by: Morgan McBee <egeekial@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Mike Bishop <mbishop@evequefou.be> Co-authored-by: ago1776 <andi.goepfert@gmx.de> Co-authored-by: pascalpfammatter <pascal.pfammatter@mirific.ch> Co-authored-by: Pascal Pfammatter <304897782+pascalpfammatter@users.noreply.github.com> Co-authored-by: Anders Demant van der Weide <antra@antra.dk> Co-authored-by: Bas Buijsen <72604903+deBasMan21@users.noreply.github.com> Co-authored-by: Parman Mohammadalizadeh <prmma23@gmail.com> Co-authored-by: alkank <alkankizilca@gmail.com> Co-authored-by: Henkka <121262594+i-xul@users.noreply.github.com> Co-authored-by: Ercan Ermis <18646235+flightlesstux@users.noreply.github.com> Co-authored-by: Jamal Kamaladdinoglu <jamalkamaladdin@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A birthday in my Google Calendar showed up on my mirror as "tomorrow" when it is actually in October. It turns out it had been appearing on the 2nd of every month, and I had simply never noticed until it landed on a day I paid attention to.
The event looks like this in the ICS feed:
FREQ=YEARLYtogether withBYMONTHDAY=2, but noBYMONTH.BYMONTHDAYis an expanding rule part forFREQ=YEARLY, so the expander returns the 2nd of every month — twelve occurrences a year instead of one. Google Calendar's own UI, and the client that wrote the rule, show it once a year on 2 October.Out of 3354 events in my calendar exactly two had this shape, both created by an older mobile calendar app. Newer clients write a plain
RRULE:FREQ=YEARLY, which is why this is easy to miss — but the old events are never rewritten, so they keep misbehaving forever.Why not fix it in the RRULE library
This has come up before, and each time it was closed as bad input data rather than fixed: #2547 (2021), #3047 (2023), and recently jens-maus/node-ical#531. In that last one a fix was actually written (ggaabe/rrule-temporal#127) and then withdrawn, because a reviewer pointed out that restricting yearly
BYMONTHDAYrules to theDTSTARTmonth breaks legitimate rules such asFREQ=YEARLY;BYMONTHDAY=1,3, which really must expand across all twelve months.That objection is correct, and it is why I don't think the RRULE engine is the right place for this. A general-purpose expander has to follow RFC 5545. A calendar display, on the other hand, can afford to be liberal about a well-known broken export as long as the detection is narrow enough that no correct rule is touched.
What this does
expandRecurringEventconfines a yearly rule toDTSTART's month, but only when the rule is provably a redundant restatement ofDTSTARTrather than a real expansion. All of these must hold:FREQ=YEARLYBYMONTHDAYholds exactly one valueDTSTART's day-of-monthBYMONTH,BYDAY,BYYEARDAY,BYWEEKNOorBYSETPOSUnder those conditions the expansion yields
DTSTART's own date plus eleven dates the author never wrote, so dropping the extras cannot lose a real occurrence.FREQ=YEARLY;...;BYMONTHDAY=2, DTSTART Oct 2FREQ=YEARLY;BYMONTHDAY=1,3FREQ=YEARLY;BYMONTHDAY=13;BYDAY=FRBYDAYpresentFREQ=YEARLY;BYMONTHDAY=7, DTSTART on the 2ndFREQ=YEARLY;BYMONTHDAY=2;BYMONTH=10BYMONTHpresentFREQ=YEARLYBYMONTHDAYThe rule is read through node-ical's public
options, and both the current stringfreqand the older numeric one are accepted.Tests
Six new cases in
calendar_fetcher_utils_spec.js. The five "unaffected" rows above are tests that already pass without the change — they are there to pin the behaviour that must not regress. Only the first one fails before the fix.Calendar unit tests pass on
Europe/Zurich,America/New_York,Pacific/Auckland,Asia/KolkataandUTC.lint:jsandlint:prettierare clean.Against my own feed over the next 365 days the number of occurrences goes from 132 to 110, and the difference is exactly the 22 spurious instances from those two events — nothing else changes.
One thing worth deciding
I've made this unconditional, since the detection is narrow enough that no correct rule is affected. If you would rather have it behind a calendar config option, say so and I'll move it.