“Repeat every year” sounds like a simple instruction—until the original date is February 29.

While contributing to Super Productivity, I worked on a recurrence bug involving a task that started on a leap day. In a non-leap year, the application could create an extra overdue task before the next occurrence was due.

The bug demonstrated an important distinction:

Duration arithmetic measures elapsed time. Calendar arithmetic models human schedules.

Those concepts overlap on ordinary dates, but they can produce different answers around leap years, month boundaries, daylight-saving transitions, and multi-year recurrence rules.

The bug

The reported task repeated yearly from February 29.

Because February 29 does not exist every year, the application needed a policy for non-leap years. In this case, the intended behavior was:

February 29, 2024 February 28, 2025 February 28, 2026 February 28, 2027 February 29, 2028 

In the failing path, setting the day to 29 on a February 2025 date overflowed to March 1. The calculation then stepped back one year while preserving March 1, making March 1, 2024 appear later than the already-created February 29, 2024 occurrence.

That disagreement caused the application to materialize another overdue 2024 task.

The visible problem was a duplicate task, but the underlying problem was more interesting: different parts of the scheduling logic disagreed about which calendar occurrence existed.

JavaScript normalizes out-of-range date components

JavaScript’s Date setters normalize out-of-range components instead of rejecting them.

For example:

const date = new Date(2025, 1, 1); // February 1, 2025 date.setDate(29); console.log(date); // March 1, 2025 

There is no February 29 in 2025, so JavaScript carries the extra day into March.

This behavior is valid and sometimes useful. But it is not necessarily the recurrence policy your product intends.

For a yearly task, several behaviors could be reasonable:

  1. Move the occurrence to February 28.
  2. Move it to March 1.
  3. Skip non-leap years.
  4. Ask the user to select a policy.

The important part is not choosing one universally correct answer. The important part is making the rule explicit and applying it consistently.

Super Productivity’s expected rule was to clamp the date to the last valid day of the month: February 28.

Calendar math and duration math answer different questions

Consider the phrase “one year later.”

It could mean an elapsed duration:

const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; const later = new Date(original.getTime() + ONE_YEAR_IN_MS); 

But that assumes every year represents the same duration. Leap years immediately break that assumption.

It could instead mean a calendar operation:

Keep the original month and day. Move to the target calendar year. If the day does not exist, apply the recurrence policy. 

These are different models.

Duration math is appropriate for questions such as:

  • Has a 30-minute timeout expired?
  • How long did this request take?
  • Has a 24-hour retention period passed?

Calendar math is more appropriate for:

  • Repeat this task every year.
  • Run payroll on the last day of each month.
  • Renew this subscription every three months.
  • Schedule an event every Monday.
  • Trigger an anniversary reminder.

A recurring task expresses human calendar intent. It is not merely a number of milliseconds after another timestamp.

Separate the recurrence interval from the occurrence date

The fix became clearer when I separated the problem into two questions.

1. Is this an eligible recurrence year?

Suppose the task started in 2024 and repeats every two years.

The eligible years should remain anchored to the original year:

2024, 2026, 2028, 2030... 

Conceptually:

const yearsSinceStart = candidateYear - startYear; const isEligibleYear = yearsSinceStart >= 0 && yearsSinceStart % repeatEvery === 0; 

This uses calendar-year positions rather than asking whether a fixed duration has elapsed.

2. What is the actual occurrence in that year?

Once the year is eligible, the application needs to construct a valid date.

A simplified model looks like this:

function getOccurrence(startDate, targetYear) { const month = startDate.getMonth(); const requestedDay = startDate.getDate(); const lastValidDay = getLastDayOfMonth(targetYear, month); return new Date( targetYear, month, Math.min(requestedDay, lastValidDay), ); } 

This is illustrative pseudocode rather than the project’s implementation.

For a February 29 start date:

Target year: 2025 Requested day: 29 Last valid day in February: 28 Actual occurrence: February 28, 2025 

The application can then compare normalized calendar dates in the same timezone:

const isDue = occurrenceDay <= todayDay; 

That is safer than comparing against an out-of-range component and relying on automatic normalization. In production code, occurrenceDay and todayDay should use the same calendar and timezone policy.

Why multi-year repeats matter

A fix that only handles yearly repetition can still introduce another bug.

Consider a task that starts on February 29, 2024 and repeats every two years.

The expected sequence is:

February 29, 2024 February 28, 2026 February 29, 2028 

If the algorithm advances from each normalized result, it may accidentally treat February 28 as the new permanent anchor. It can also drift into the wrong recurrence year if eligibility is based on fully elapsed durations rather than calendar-year intervals.

The original rule therefore needs to remain the source of truth:

Original month: February Original day: 29 Original year: 2024 Repeat interval: 2 calendar years 

Each occurrence should be projected from that rule. It should not be derived solely from the previous adjusted occurrence.

This was one reason the change needed to cover both the forward and backward recurrence calculations. Fixing only the “next occurrence” path would still leave another part of the system interpreting the same rule differently.

Testing the boundaries

Date bugs often hide in the boundaries, so testing only an ordinary date such as June 15 would provide little confidence.

The regression coverage included cases around:

  • February 28 in a non-leap year
  • February 29 in a leap year
  • March 1 after the adjusted occurrence
  • An occurrence that had already been created
  • Yearly repetition
  • Two-year repetition
  • Forward and backward recurrence calculations
  • Different timezone configurations

One particularly important scenario was:

Start date: February 29 Non-leap-year occurrence: February 28 Current date: March 1 February 28 occurrence already created Expected result: no additional occurrence 

That test protects the user-visible behavior rather than only checking an internal date transformation.

It asks the question that matters:

Given the task’s history and today’s date, should another task be created?

The answer must be no.

Timezones are a separate source of risk

Leap years were central to this bug, but timezone testing still mattered.

A calendar date is not always equivalent to a fixed period from another calendar date. Around daylight-saving transitions, a local day may contain 23 or 25 hours.

That means code like this can also be dangerous for calendar scheduling:

const nextDay = new Date( current.getTime() + 24 * 60 * 60 * 1000, ); 

It adds exactly 24 elapsed hours. It does not necessarily mean “the same local time tomorrow.”

Testing the recurrence logic under more than one timezone helped confirm that the result represented the intended calendar date rather than an accidental property of one development environment.

What I learned

1. Recurrence rules are domain logic

“Every year” is not a low-level timestamp operation. It is a product rule that needs defined behavior for invalid dates.

2. Invalid dates should be handled intentionally

JavaScript’s normalization rules are predictable, but predictable does not mean correct for every domain.

3. Preserve the original anchor

Adjusted occurrences should not silently replace the original recurrence rule.

4. Forward and backward calculations must agree

If one function searches for the next occurrence while another searches for the newest possible due occurrence, both must implement the same calendar policy.

5. Test the dates around the boundary

For a February 29 rule, the most valuable tests are usually February 28, February 29, and March 1—not an arbitrary date in the middle of the year.

6. Test outcomes, not only transformations

Checking that February 29 becomes February 28 is useful. Checking that no duplicate task is created after that occurrence already exists is better.

A practical rule

First decide what the value represents. Use duration arithmetic for elapsed time—timeouts, performance measurements, and expiry windows. Use calendar arithmetic for human schedules—billing dates, anniversaries, weekday meetings, and recurring reminders.

Then define what should happen when the requested calendar date does not exist.

Final thoughts

This was a small open-source contribution, but it reinforced a broader engineering lesson: date bugs are often specification bugs hiding inside arithmetic.

The difficult question was not how to manipulate a JavaScript Date. It was deciding what “repeat every year” meant and ensuring every code path implemented that meaning consistently.

The proposed fix is available in Super Productivity PR #10724, created for issue #10679.

At the time of writing, the pull request is still open.

If there is one rule worth remembering, it is this:

Use duration arithmetic for elapsed time. Use calendar arithmetic for human schedules.