“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:
- Move the occurrence to February 28.
- Move it to March 1.
- Skip non-leap years.
- 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.
Top comments (6)
himanshu, this is a masterclass in domain-driven date logic. the distinction between "duration math" (elapsed time) and "calendar math" (human schedules) is a trap that catches even experienced developers.
your point about javascript silently normalizing out-of-range dates (pushing feb 29 to march 1) is the exact reason why relying on native date arithmetic for recurrence rules is so dangerous. anchoring to the original rule and explicitly clamping to the last valid day of the month is the only robust solution.
this perfectly mirrors the constraint-driven philosophy i enforce: never rely on implicit, silent behaviors from the language or framework. make the boundary conditions explicit and test them ruthlessly.
quick question: for projects where you want to keep dependencies at absolute zero, do you find this custom clamping logic is enough, or do you eventually reach for a library like
luxonordate-fnsto handle the timezone/dst edge cases that inevitably creep in?fantastic, deeply practical open-source contribution! 🐯📅
Thanks, Harun. That is pretty much where I draw the line too. Custom clamping works well when I am dealing with calendar dates and the rules are clear, such as keeping the original day or using the last valid day of the month.
Once time zones, daylight saving changes, or scheduling across regions enter the picture, I would use Temporal or a trusted date library. Keeping dependencies at zero is useful, but not when it means rebuilding complicated date logic ourselves.
spot on, himanshu. the upcoming
temporalapi is exactly the right call for that level of complexity.knowing when to drop the "zero dependency" rule is what separates a pragmatic engineer from a stubborn one. reinventing the wheel for dst edge cases is a trap we should all avoid.
thanks for the great discussion and for writing such a clear, practical article! 🐯📅
Does the calendar comparison use the task's timezone or the user's current timezone when those differ?
Official Platform Update
Security protocols have been updated for all developer accounts.
THIS IS A PHISHING SCAM 🚨 Do not click this link. Dev.to will never ask you to verify your account via a third-party link in the comments.
Some comments have been hidden by the post's author - find out more