How do I handle calendar edge cases like month-end and leap years in EK9?

← Date, Time, and Duration · Ref: Q550

EK9 uses a 30-day month approximation for Duration arithmetic. This makes month arithmetic predictable and associative, at the cost of exact calendar precision.

THE DURATION SUMMING PROBLEM (Deep Dive)
In Java and Python, month arithmetic uses true calendar months:

  Java: Jan 31 + 1 month = Feb 28 (clamped)
        Feb 28 + 1 month = Mar 28
  But:  Jan 31 + 2 months = Mar 31 (different!)

This happens because each addition clamps to the month's length, and the clamping at each intermediate step loses information. Month arithmetic is NOT associative: (a + b) + c is not equal to a + (b + c). This creates subtle bugs when accumulating monthly intervals.

EK9'S APPROACH

P1M = exactly 30 days (30 x 86400 seconds). Always.
P1Y = 360 days (12 x 30). Always.

This means:

  date + P1M + P1M = date + 60 days
  date + P2M = date + 60 days (same!)

Month arithmetic IS associative. P1M * 2 == P2M. Duration addition is commutative and associative. No surprises from intermediate clamping.

THE TRADE-OFF

EK9 durations are predictable but approximate:

  Feb 1 + P1M = Mar 3 (30 days from Feb 1), not Feb 28
  Jan 31 + P1M = Mar 2 (30 days from Jan 31), not Feb 28

For calendar-precise month logic, construct new dates from components:

  currentMonth <- date.month()
  nextMonthDate <- Date(date.year(), currentMonth + 1, date.day())

This uses the calendar's own month boundaries.

LEAP YEARS

Date handles leap years correctly in its calendar:

  Date(2024, 02, 29) is valid (2024 is a leap year)
  Date(2023, 02, 29) is invalid (produces unset)

But Duration arithmetic with P1Y adds 360 days, not 365 or 366.

DESIGN RATIONALE

Most duration use cases (scheduling, timeouts, intervals) need predictability more than calendar precision. 'Add 2 months' meaning '60 days' is always correct for billing cycles, subscription periods, and timeout calculations. True calendar month arithmetic is only needed for UI features like 'same day next month'.

See Q32 for Duration basics. See Q540 for the duration summing pitfall introduction. See Q539 for date subtraction. See Q556 for Time wrapping.

Example

defines module qa.calendar.edge.cases

  defines program
    CalendarEdgeCasesDemo()
      stdout <- Stdout()

      // Leap year handling
      leapDate <- Date(2024, 02, 29)
      stdout.println(`Leap day 2024: ${leapDate}`)
      require leapDate?

      // Non-leap year: Feb 29 is invalid -> unset
      noLeap <- Date(2023, 02, 29)
      require ~noLeap?
      stdout.println(`Feb 29 2023 isSet: ${noLeap?}`)

      // P1M = 30 days, always
      jan31 <- 2024-01-31
      plusOneMonth <- jan31 + P1M
      plusTwoMonths <- jan31 + P2M
      plusOneOne <- jan31 + P1M + P1M
      stdout.println(`Jan 31 + P1M: ${plusOneMonth}`)
      stdout.println(`Jan 31 + P2M: ${plusTwoMonths}`)
      stdout.println(`Jan 31 + P1M + P1M: ${plusOneOne}`)

      // Associative: P1M + P1M == P2M
      require plusTwoMonths == plusOneOne
      stdout.println(`P2M == P1M + P1M: ${plusTwoMonths == plusOneOne}`)

      // Duration math is predictable
      oneMonth <- P1M
      twoMonths <- P2M
      require oneMonth + oneMonth == twoMonths
      require oneMonth * 2 == twoMonths
      stdout.println(`P1M + P1M == P2M: ${oneMonth + oneMonth == twoMonths}`)

      // P1Y = 360 days
      oneYear <- P1Y
      twelveMonths <- P1M * 12
      require oneYear == twelveMonths
      stdout.println(`P1Y == P1M * 12: ${oneYear == twelveMonths}`)

      // Calendar-precise month logic: construct from components
      baseDate <- 2024-01-31
      nextMonth <- Date(baseDate.year(), baseDate.month() + 1, 28)
      stdout.println(`Calendar next month end: ${nextMonth}`)

      // Month-end dates
      march31 <- 2024-03-31
      plus30days <- march31 + P1M
      stdout.println(`Mar 31 + P1M (30 days): ${plus30days}`)

Common mistakes

E50060 — Duration has no plus() method. EK9 uses the + operator for duration arithmetic. See ek9 -h E50060 for details.

Incorrect:

require oneMonth.plus(oneMonth) == twoMonths

Correct:

require oneMonth + oneMonth == twoMonths
Other ways to ask this
  • Does EK9 handle leap years correctly?
  • What happens when adding a month to January 31st?
  • Why is month arithmetic non-associative in other languages?

Coming from another language?

Java: YearMonth, LocalDate.plusMonths() handles month-end clamping (Jan 31 + 1M = Feb 28) but is non-associative. Period.ofMonths(1) stored as months, applied at calculation time. Python: relativedelta(months=+1) same clamping behavior. Go: AddDate(0,1,0) normalizes (Jan 31 + 1M = Mar 2 or 3 depending on Feb). EK9: P1M = 30 days always, associative, predictable. Calendar-precise month logic via component construction.

Keywords: month-end, clamping, summing, predictable, edge case, migrate, associative, 30 days, leap year, calendar, timezone, duration, time