DEV Community

Cover image for Your Calendar UI Is Probably Wrong: 12 Invariants That Break Month Views
Pedersen Mateo
Pedersen Mateo

Posted on

Your Calendar UI Is Probably Wrong: 12 Invariants That Break Month Views


A calendar looks like one of the simplest interfaces in software: seven columns, four to six rows, and numbers from 1 to 31.

That simplicity is deceptive.

The moment a calendar has to survive localization, printing, daylight-saving transitions, different week starts, responsive layouts, accessibility, date-only data, week numbering, leap years, and multiple rendering targets, it stops being a grid of numbers and becomes a small temporal system.

The dangerous calendar bugs are rarely spectacular. They are the ones that look completely correct in a screenshot while quietly doing something wrong:

  • moving an all-day event to the previous date,
  • starting the week on the wrong day,
  • allocating the wrong number of rows,
  • treating February as a special UI exception,
  • using elapsed milliseconds as calendar arithmetic,
  • breaking around DST,
  • changing week identity when the locale changes,
  • or producing a printed calendar that disagrees with the browser version.

A useful way to design calendar software is to stop asking:

"Does this month look right?"

and start asking:

"What properties must remain true regardless of year, locale, time zone, screen size, or rendering target?"

Those properties are calendar invariants.

For visual comparison while testing implementations, month-oriented references such as Beta Calendars are useful because they let you inspect the final human-facing layout independently from the code generating it.

The important point is that the visual reference should validate the implementation, not become the implementation.


1. A calendar date is not a timestamp

The first architectural mistake appears before anything is rendered.

Consider:

const date = new Date("2027-08-01");
Enter fullscreen mode Exit fullscreen mode

That looks like "August 1, 2027."

But JavaScript's Date fundamentally models an instant on a timeline.

That is not the same thing as a civil date.

If an application stores a birthday, holiday, deadline, or calendar cell as an instant, the displayed date can change depending on the time zone used to interpret that instant.

A month grid usually wants something closer to:

const date = Temporal.PlainDate.from("2027-08-01");
Enter fullscreen mode Exit fullscreen mode

A PlainDate has:

year
month
day
Enter fullscreen mode Exit fullscreen mode

but deliberately has no:

hour
minute
offset
time zone
Enter fullscreen mode Exit fullscreen mode

That difference is enormously important.

If a user selected August 1, the value should remain August 1 whether the software is running in Istanbul, London, New York, Tokyo, or SĆ£o Paulo.

A human-readable August calendar makes the expectation obvious: the cell containing 1 represents August 1. It should not mean "an instant that happens to display as August 1 under the current offset."

That gives us the first invariant:

Invariant 1: A date-only calendar cell must remain the same civil date in every time zone.

If changing the machine's time zone can turn August 1 into July 31, the calendar model is leaking timestamp semantics into civil-date data.


2. "Tomorrow" is not always 86,400,000 milliseconds later

A closely related mistake is using duration arithmetic for calendar arithmetic.

This looks reasonable:

const tomorrow = new Date(
  today.getTime() + 24 * 60 * 60 * 1000
);
Enter fullscreen mode Exit fullscreen mode

But "one calendar day later" and "86,400,000 milliseconds later" are not the same abstraction.

Clock time can be affected by offset changes.

A civil calendar day should not care.

For date-only navigation, the intent should be represented directly:

const tomorrow = today.add({ days: 1 });
Enter fullscreen mode Exit fullscreen mode

That says exactly what the application means.

The distinction becomes even more important when a calendar application also handles appointments.

These two concepts should not share the same mental model:

2027-03-14
Enter fullscreen mode Exit fullscreen mode

and:

2027-03-14T09:30:00-04:00
Enter fullscreen mode Exit fullscreen mode

The first is a date.

The second is a time-zone-aware moment.

A robust architecture treats them differently.

Invariant 2: Date-only navigation must be expressed as calendar arithmetic, not elapsed milliseconds.

DST belongs to timeline calculations.

It should not be able to corrupt the geometry of a month grid.


3. A month is geometry, not a fixed rectangle

A surprising number of calendar components assume that a month is:

7 columns Ɨ 6 rows
Enter fullscreen mode Exit fullscreen mode

That can be a valid presentation policy.

It is not the natural geometry of every month.

The number of required rows depends on three things:

number of days in the month
weekday of the first day
first weekday used by the locale
Enter fullscreen mode Exit fullscreen mode

Let:

D = number of days in the month
W = weekday of the first date
S = first weekday of the displayed week
L = number of leading cells
Enter fullscreen mode Exit fullscreen mode

Then:

L = (W - S + 7) mod 7
Enter fullscreen mode Exit fullscreen mode

and:

rows = ceil((L + D) / 7)
Enter fullscreen mode Exit fullscreen mode

That tiny equation explains why a month may need four, five, or six rows.

It also explains why changing the locale can change the height of a month without changing a single date.

This produces another invariant:

Invariant 3: Natural month height is derived from calendar geometry, not hard-coded component dimensions.

A product can still normalize all month cards to six rows.

But it should first know the correct natural row count.

Correctness should come before normalization.


4. February 2027 is a nearly perfect calendar test

February 2027 is especially interesting.

It contains 28 days.

Its first day is Monday.

In a Monday-first calendar:

Mon Tue Wed Thu Fri Sat Sun
  1   2   3   4   5   6   7
  8   9  10  11  12  13  14
 15  16  17  18  19  20  21
 22  23  24  25  26  27  28
Enter fullscreen mode Exit fullscreen mode

That is a legitimate four-row month.

There is no missing row.

There is no special February trick.

The geometry simply fits exactly into four complete weeks.

A visual February calendar is useful here because this is the kind of layout developers often accidentally "correct" into five or six rows.

Now render the same month Sunday-first:

Sun Mon Tue Wed Thu Fri Sat
      1   2   3   4   5   6
  7   8   9  10  11  12  13
 14  15  16  17  18  19  20
 21  22  23  24  25  26  27
 28
Enter fullscreen mode Exit fullscreen mode

The dates are identical.

The month is identical.

Only the week-start convention changed.

Yet the natural row count changed from four to five.

Invariant 4: Locale may change month geometry without changing date identity.

That is an important architectural distinction.


5. Week start is locale data

A calendar often contains something like:

const weekStartsOnMonday = true;
Enter fullscreen mode Exit fullscreen mode

or:

const weekStartsOnSunday = true;
Enter fullscreen mode Exit fullscreen mode

That may be sufficient for a narrowly targeted application.

It is not a general solution.

Modern JavaScript can expose locale week conventions using Intl.Locale:

function getWeekRules(locale) {
  const info = new Intl.Locale(locale).getWeekInfo();

  return {
    firstDay: info.firstDay,
    weekend: info.weekend,
    minimalDays: info.minimalDays,
  };
}
Enter fullscreen mode Exit fullscreen mode

Now week structure becomes data rather than an assumption.

This is especially important around months such as January, August, and October, where the first weekday can produce substantially different visible layouts depending on the locale.

A strong calendar model therefore separates:

date generation
Enter fullscreen mode Exit fullscreen mode

from:

grid placement
Enter fullscreen mode Exit fullscreen mode

A date should exist before the UI decides which visual column contains it.

Invariant 5: Locale rules may influence presentation, but they must never mutate the underlying civil date.


6. Build a month model before building the DOM

One of the most useful calendar architecture decisions is surprisingly simple:

Do not calculate dates while creating UI elements.

Instead of generating <div> elements and dates simultaneously, build a pure month model first.

For example:

function buildMonthModel(year, month, locale = "en-US") {
  const first = Temporal.PlainDate.from({
    year,
    month,
    day: 1,
  });

  const weekInfo = new Intl.Locale(locale).getWeekInfo();

  const firstDay = weekInfo.firstDay;

  const leading =
    (first.dayOfWeek - firstDay + 7) % 7;

  const daysInMonth = first.daysInMonth;

  const naturalRows =
    Math.ceil((leading + daysInMonth) / 7);

  const totalCells = naturalRows * 7;

  const cells = Array.from(
    { length: totalCells },
    (_, index) => {
      const day = index - leading + 1;

      if (day < 1 || day > daysInMonth) {
        return {
          kind: "outside",
          date: null,
        };
      }

      const date = first.with({ day });

      return {
        kind: "day",
        date,
        day,
        dayOfWeek: date.dayOfWeek,
      };
    }
  );

  return {
    year,
    month,
    locale,
    firstDay,
    leading,
    daysInMonth,
    naturalRows,
    cells,
  };
}
Enter fullscreen mode Exit fullscreen mode

The renderer now receives data.

That renderer can decide whether it wants:

natural row count
always six rows
blank outside cells
adjacent-month dates
week numbers
mobile presentation
print presentation
PDF presentation
Enter fullscreen mode Exit fullscreen mode

without rewriting date arithmetic.

The month model can even be reused for different products.

One model might power:

web calendar
mobile calendar
printable calendar
PDF export
JSON endpoint
email rendering
server-side HTML
Enter fullscreen mode Exit fullscreen mode

This gives us:

Invariant 6: Rendering policy must not alter the underlying calendar model.


7. 2027 is a surprisingly useful test vector

One reason 2027 is useful for calendar engineering is that its months naturally exercise several different grid shapes.

Month Starts Days Monday-first rows Sunday-first rows
January Friday 31 5 6
February Monday 28 4 5
March Monday 31 5 5
April Thursday 30 5 5
May Saturday 31 6 6
June Tuesday 30 5 5
July Thursday 31 5 5
August Sunday 31 6 5
September Wednesday 30 5 5
October Friday 31 5 6
November Monday 30 5 5
December Wednesday 31 5 5

That one year gives us several useful classes of test.

February: four-row compression

The February calendar is a clean test for whether a renderer can represent a natural four-row month.

May: unavoidable six-row geometry

The May calendar requires six rows under both Monday-first and Sunday-first layouts.

That makes May useful for detecting implementations that accidentally assume five rows.

August: locale-sensitive row count

The August calendar needs six natural rows Monday-first but only five Sunday-first.

That is an excellent test because the number of visible rows changes without changing the year, month, or dates.

January and October: Sunday-first expansion

Both January and October illustrate another useful case: a 31-day month beginning on Friday can require an additional row depending on which weekday begins the grid.

This is why a year is more useful to developers as a test matrix than merely as twelve rendered pages.


8. Month navigation is not "add 30 days"

A related modeling error appears in previous/next navigation.

This is wrong in principle:

const nextMonth = new Date(
  current.getTime() +
  30 * 24 * 60 * 60 * 1000
);
Enter fullscreen mode Exit fullscreen mode

Months have different lengths.

February exists.

Leap years exist.

DST exists.

Month navigation should be expressed as month navigation:

const current =
  Temporal.PlainYearMonth.from("2027-03");

const next =
  current.add({ months: 1 });

console.log(next.toString());
// 2027-04
Enter fullscreen mode Exit fullscreen mode

This sounds obvious, yet a huge amount of date software still converts calendar operations into elapsed durations.

Looking at adjacent month references makes the problem intuitive:

March has 31 days.

April has 30.

May returns to 31.

The operation:

next month
Enter fullscreen mode Exit fullscreen mode

is not equivalent to:

+30 days
Enter fullscreen mode Exit fullscreen mode

Invariant 7: Calendar-unit navigation must remain calendar-unit arithmetic.

The same principle applies to years.

+1 year is a semantic operation.

It should not be modeled as a fixed number of seconds.


9. Leap years are easy until century boundaries appear

Most developers remember:

year % 4 === 0
Enter fullscreen mode Exit fullscreen mode

But the Gregorian leap-year rule is:

function isLeapYear(year) {
  return (
    year % 400 === 0 ||
    (
      year % 4 === 0 &&
      year % 100 !== 0
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

Therefore:

1900 → not leap
2000 → leap
2100 → not leap
2400 → leap
Enter fullscreen mode Exit fullscreen mode

If a calendar application's test suite only covers the previous five years and the next five years, these failures may remain hidden for decades.

A better design asks the temporal object for the number of days in the month:

const feb =
  Temporal.PlainDate.from("2100-02-01");

console.log(feb.daysInMonth);
Enter fullscreen mode Exit fullscreen mode

That keeps Gregorian rules in the calendar layer rather than duplicating them across components.

A clean architectural rule is:

Calendar mathematics belongs to the temporal domain layer, not the UI layer.

The UI should not need a special if (month === 2) branch just to know how many dates to render.


10. Week layout and week identity are different concepts

A particularly subtle bug appears when developers assume that moving the first visible weekday also changes the identity of the week.

Week placement and week numbering are related.

They are not identical.

A robust system can think about the process as two separate pipelines:

PlainDate
   ↓
weekday
   ↓
locale first day
   ↓
visual column
Enter fullscreen mode Exit fullscreen mode

and:

PlainDate
   ↓
week-numbering rules
   ↓
week-year
   ↓
week number
Enter fullscreen mode Exit fullscreen mode

Why does this matter?

Because a date near New Year's Day can belong to a week-year different from its calendar year.

That is especially important around December and January.

A financial system may care about ISO week 1.

A visual calendar may begin the week on Sunday.

Those two requirements should not be forced into one piece of logic.

Invariant 8: Changing visual week placement must not silently redefine week identity.

This matters in payroll, analytics, logistics, sprint systems, reporting dashboards, manufacturing schedules, and enterprise planning.


11. DST should be architecturally unable to corrupt the month grid

This is an unusually useful design test.

Ask:

Can a daylight-saving transition change the number of cells in my month grid?

If the answer is yes, the architecture is probably mixing abstractions.

A monthly calendar's structure is based on civil dates.

DST changes offsets attached to timeline instants.

Those are different layers.

Think of the system like this:

CIVIL CALENDAR DOMAIN

2027-03-14
2027-03-15
2027-03-16

          │

          │ only combine when time-of-day
          │ and time-zone semantics matter

          ā–¼

TIMELINE DOMAIN

2027-03-14T09:00 America/New_York
2027-03-14T13:00Z
Enter fullscreen mode Exit fullscreen mode

The first layer determines calendar cells.

The second layer determines moments.

A scheduling application eventually needs both.

But it should combine them intentionally.

This gives us:

Invariant 9: Timeline offset changes must not alter date-only calendar geometry.

That invariant is much stronger than testing one known DST transition.

It tests the architecture.


12. Stable-height calendar cards are a presentation policy

Designers often want every month card to have identical height.

That is completely reasonable.

Suppose February naturally needs four rows.

The design system may require six.

Do this conceptually:

const model =
  buildMonthModel(2027, 2, "en-GB");

console.log(model.naturalRows);
// 4

const renderedRows =
  Math.max(model.naturalRows, 6);
Enter fullscreen mode Exit fullscreen mode

Now both pieces of information survive:

calendar truth:
February naturally requires 4 rows

presentation truth:
the component renders 6 rows
Enter fullscreen mode Exit fullscreen mode

Those are not contradictory.

They are different layers.

The dangerous implementation is one where the date generator pretends February naturally has six rows.

Then other systems cannot distinguish real geometry from normalization.

Invariant 10: Presentation normalization must remain observable as presentation policy.

That principle applies beyond calendars.

Whenever a UI stabilizes, truncates, pads, sorts, groups, or reformats domain data, the normalization should not destroy the original meaning.


13. Screen and print are different rendering targets

A browser calendar and a printed calendar share data.

They do not necessarily share layout.

Print introduces a completely different set of constraints:

physical page dimensions
printer margins
page breaks
orientation
ink usage
background rendering
font metrics
browser print scaling
pagination
Enter fullscreen mode Exit fullscreen mode

A calendar can look perfect at 1440px wide and become unusable on A4.

At minimum, print deserves its own styling layer:

.calendar {
  display: grid;
}

@media print {
  .toolbar,
  .navigation,
  .screen-only {
    display: none !important;
  }

  .calendar {
    break-inside: avoid;
  }

  .month {
    break-inside: avoid-page;
  }
}

@page {
  size: A4 landscape;
  margin: 12mm;
}
Enter fullscreen mode Exit fullscreen mode

But the important part is not the CSS syntax.

It is the architecture:

one date model
      │
      ā”œā”€ā”€ browser presentation
      │
      └── print presentation
Enter fullscreen mode Exit fullscreen mode

The browser and printed page may use different:

spacing
font sizes
margins
orientation
controls
decorative elements
Enter fullscreen mode Exit fullscreen mode

They must not disagree about where the dates belong.

Invariant 11: Screen and print may render differently, but they must represent the same calendar model.

Human-readable month references can help here because a developer can compare the output month by month:

January Ā·
February Ā·
March Ā·
April Ā·
May Ā·
June Ā·
July Ā·
August Ā·
September Ā·
October Ā·
November Ā·
December.


14. Accessibility is part of calendar correctness

Calendar accessibility is sometimes treated as a later enhancement.

That is a mistake.

Calendar semantics are closely related to the data model.

A mostly static month view is inherently tabular.

A semantic representation can begin with:

<table>
  <caption>August 2027</caption>

  <thead>
    <tr>
      <th scope="col">Sun</th>
      <th scope="col">Mon</th>
      <th scope="col">Tue</th>
      <th scope="col">Wed</th>
      <th scope="col">Thu</th>
      <th scope="col">Fri</th>
      <th scope="col">Sat</th>
    </tr>
  </thead>

  <tbody>
    <!-- calendar weeks -->
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Individual dates can preserve machine-readable identity:

<time datetime="2027-08-01">1</time>
Enter fullscreen mode Exit fullscreen mode

An interactive date picker is a more complicated problem.

If it uses grid semantics, then the component must also define:

focus movement
keyboard navigation
selection state
current-date announcement
disabled-date behavior
screen-reader labels
month transitions
Enter fullscreen mode Exit fullscreen mode

Adding role="grid" without implementing corresponding interaction behavior does not make a calendar accessible.

The visual square and the semantic date should describe the same thing.


15. Month names should not be manually translated

Another common calendar anti-pattern looks like this:

const monthNames = {
  en: [
    "January",
    "February",
    "March",
    // ...
  ],

  fr: [
    "Janvier",
    "FƩvrier",
    "Mars",
    // ...
  ]
};
Enter fullscreen mode Exit fullscreen mode

For a small controlled application, that may work.

For a genuinely international calendar system, month names should normally come from internationalization APIs.

For example:

const formatter =
  new Intl.DateTimeFormat("fr-FR", {
    month: "long",
    year: "numeric",
  });
Enter fullscreen mode Exit fullscreen mode

The same idea applies to weekday names:

const weekdayFormatter =
  new Intl.DateTimeFormat("de-DE", {
    weekday: "short",
  });
Enter fullscreen mode Exit fullscreen mode

The principle is larger than translation.

Formatting conventions belong to locale-aware formatting layers.

Calendar arithmetic should not know that January is displayed as:

January
janvier
Januar
enero
gennaio
Enter fullscreen mode Exit fullscreen mode

The domain value is still month 1.


16. The calendar should survive changing locales at runtime

Here is a useful stress test.

Render the same month repeatedly using:

en-US
en-GB
de-DE
fr-FR
ar
ja-JP
tr-TR
Enter fullscreen mode Exit fullscreen mode

Then ask:

Did any dates disappear?
Did any dates duplicate?
Did the row count change legitimately?
Did the weekday headers move correctly?
Did month arithmetic remain unchanged?
Did text overflow?
Did RTL layout expose assumptions?
Enter fullscreen mode Exit fullscreen mode

For example, compare a September calendar or November calendar after changing the first weekday.

The date sequence should remain:

1, 2, 3, ... 30
Enter fullscreen mode Exit fullscreen mode

Only its presentation should move.

A mature calendar renderer can distinguish:

data invariant
Enter fullscreen mode Exit fullscreen mode

from:

layout variation
Enter fullscreen mode Exit fullscreen mode

17. Adjacent-month cells should not become fake dates

Many month grids display the final days of the previous month and first days of the following month in otherwise empty cells.

That can be useful.

But it should be represented explicitly.

Instead of:

{
  day: 30,
}
Enter fullscreen mode Exit fullscreen mode

prefer something with clear identity:

{
  kind: "adjacent",
  date: Temporal.PlainDate.from("2027-04-30"),
  relation: "previous-month",
}
Enter fullscreen mode Exit fullscreen mode

or:

{
  kind: "outside",
  date: null,
}
Enter fullscreen mode Exit fullscreen mode

depending on the product.

Why?

Because the visual label 30 alone is ambiguous.

Inside a May grid it could mean:

April 30
Enter fullscreen mode Exit fullscreen mode

or:

May 30
Enter fullscreen mode Exit fullscreen mode

The model should never need the DOM position to determine which date a cell represents.

That is a powerful general rule:

A calendar cell should contain enough domain information to identify itself without relying on where it happens to be rendered.


18. The URL should ideally encode calendar state cleanly

Calendar applications often put state into the URL.

That is good.

But calendar state should use meaningful calendar units.

Good examples:

/calendar/2027/08
/calendar/2027-08
?year=2027&month=8
Enter fullscreen mode Exit fullscreen mode

Less desirable:

?timestamp=1817078400000
Enter fullscreen mode Exit fullscreen mode

Why expose a timestamp when the user conceptually selected a month?

URL design is another place where the underlying data model leaks into product architecture.

For month-level views, a year-month representation usually communicates intent much better.

A page such as an August calendar is conceptually identified by the month itself, not by an arbitrary millisecond value corresponding to midnight in some time zone.


19. Property-based testing is more valuable than twelve screenshots

Visual regression tests are useful.

They are not sufficient.

A calendar can look correct in screenshots and still contain deep arithmetic errors.

The stronger approach is to test properties.

For example:

function assertMonth(model) {
  const days =
    model.cells.filter(
      cell => cell.kind === "day"
    );

  if (days.length !== model.daysInMonth) {
    throw new Error(
      "Incorrect number of day cells"
    );
  }

  for (let i = 0; i < days.length; i++) {
    if (days[i].day !== i + 1) {
      throw new Error(
        "Missing, duplicated, or reordered day"
      );
    }
  }

  if (
    model.naturalRows < 4 ||
    model.naturalRows > 6
  ) {
    throw new Error(
      "Unexpected Gregorian month row count"
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now exercise centuries:

for (
  let year = 1800;
  year <= 2200;
  year++
) {
  for (
    let month = 1;
    month <= 12;
    month++
  ) {
    assertMonth(
      buildMonthModel(
        year,
        month,
        "en-US"
      )
    );

    assertMonth(
      buildMonthModel(
        year,
        month,
        "en-GB"
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

You just tested:

401 years
Ɨ 12 months
Ɨ 2 locale week structures
Enter fullscreen mode Exit fullscreen mode

That is:

9,624 month configurations
Enter fullscreen mode Exit fullscreen mode

without manually creating thousands of fixtures.

Now add more locales.

Now add random years.

Now add explicit century boundaries.

Now add presentation policies.

Suddenly calendar correctness becomes something that can be reasoned about systematically.


20. Useful invariants for automated testing

A robust test suite can define calendar behavior as mathematical properties.

For every Gregorian month:

daysInMonth ∈ {28, 29, 30, 31}
Enter fullscreen mode Exit fullscreen mode

The natural row count should satisfy:

4 ≤ rows ≤ 6
Enter fullscreen mode Exit fullscreen mode

The number of in-month cells must equal:

daysInMonth
Enter fullscreen mode Exit fullscreen mode

Dates must be sequential:

1 ... daysInMonth
Enter fullscreen mode Exit fullscreen mode

No in-month date should appear twice.

Changing locale must not change:

year
month
number of dates
date identity
Enter fullscreen mode Exit fullscreen mode

Changing locale may change:

weekday labels
leading cells
column placement
row count
weekend styling
Enter fullscreen mode Exit fullscreen mode

Changing time zone must not change a PlainDate.

Changing print CSS must not change the month model.

A six-row presentation policy must not change naturalRows.

These tests are far more valuable than checking whether twelve screenshots happen to resemble last year's screenshots.


21. Visual references and algorithmic references solve different problems

A visual reference such as the month pages at Beta Calendars answers questions like:

Does this layout look like the intended month?
Are dates aligned with the expected weekdays?
Does the printed month feel balanced?
Is the human-facing result understandable?
Enter fullscreen mode Exit fullscreen mode

Algorithmic tests answer different questions:

Did every date appear exactly once?
Did the locale transformation preserve identity?
Did February receive the correct number of days?
Did a leap-century rule behave correctly?
Did row count follow the mathematical model?
Enter fullscreen mode Exit fullscreen mode

Both are useful.

Neither replaces the other.

For targeted visual inspection, it can be useful to jump directly to individual month fixtures:

January

February

March

April

May

June

July

August

September

October

November

December

The architectural principle is:

A reference artifact should help verify the implementation without becoming part of the implementation.


22. A better calendar architecture

A production-grade calendar can be decomposed into explicit layers.

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│        Domain values          │
│ PlainDate / PlainYearMonth    │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                │
                ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│      Calendar arithmetic      │
│ days / leap years / weekday   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                │
                ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│        Locale rules           │
│ week start / names / weekend  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                │
                ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│        Month geometry         │
│ leading cells / rows / cells  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                │
                ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│    Presentation policies      │
│ fixed rows / adjacent dates   │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
                │
       ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
       │                  │
       ā–¼                  ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”    ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│ Screen view  │    │ Print / PDF  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜    ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
Enter fullscreen mode Exit fullscreen mode

This structure is powerful because each layer can be tested independently.

The calendar engine does not care whether React, Vue, Svelte, server-side templates, or plain HTML will display the result.

The month geometry does not care about printer margins.

The print renderer does not need to know Gregorian leap-year rules.

The locale formatter does not need to calculate month length.

That is what separation of concerns looks like in temporal software.


23. Make invalid temporal states difficult to represent

A useful mental model is to give different concepts different types.

PlainDate
    civil calendar date

Instant
    exact point on the timeline

ZonedDateTime
    timeline instant interpreted in a time zone

PlainYearMonth
    calendar month

Duration
    amount of time

Locale
    cultural display conventions

MonthModel
    month geometry

MonthView
    presentation policy
Enter fullscreen mode Exit fullscreen mode

Problems begin when a single type is forced to mean everything.

Historically, developers often make Date represent all of these:

birthday
appointment
month heading
all-day event
deadline
UTC timestamp
local wall-clock time
selected calendar day
Enter fullscreen mode Exit fullscreen mode

That may feel convenient because everything uses one API.

But the complexity has not disappeared.

It has merely moved into invisible assumptions.

The calendar component now has to remember which interpretation is intended every time it receives a value.

A better architecture lets the type carry some of that meaning.

Invariant 12: Distinct temporal concepts should remain distinct until the layer that intentionally combines them.

That is probably the most important invariant in this entire article.


24. A small debugging exercise

Suppose a user reports:

"August is showing six rows on my computer but five rows on my colleague's computer."

A fragile system treats this as a CSS bug.

A better debugging process asks:

Are both users viewing the same year?
Are both users using the same locale?
Does one locale start weeks on Monday?
Does the other start on Sunday?
Is natural row count being used?
Is the component intentionally normalized?
Enter fullscreen mode Exit fullscreen mode

For August 2027, the difference may be entirely correct.

The August reference can help confirm the underlying dates visually, but the explanation comes from the geometry:

31 days
starts Sunday
Enter fullscreen mode Exit fullscreen mode

Sunday-first:

0 leading cells + 31 days
ceil(31 / 7) = 5 rows
Enter fullscreen mode Exit fullscreen mode

Monday-first:

6 leading cells + 31 days
ceil(37 / 7) = 6 rows
Enter fullscreen mode Exit fullscreen mode

Same month.

Same dates.

Different valid geometry.

This is why calendar bugs often cannot be understood from a screenshot alone.

You need the temporal context.


25. Another debugging exercise: October 2027

October 2027 starts on Friday and contains 31 days.

In a Monday-first grid:

Mon Tue Wed Thu Fri Sat Sun
                  1   2   3
  4   5   6   7   8   9  10
 11  12  13  14  15  16  17
 18  19  20  21  22  23  24
 25  26  27  28  29  30  31
Enter fullscreen mode Exit fullscreen mode

Five rows.

Now consider Sunday-first placement:

Sun Mon Tue Wed Thu Fri Sat
                      1   2
  3   4   5   6   7   8   9
 10  11  12  13  14  15  16
 17  18  19  20  21  22  23
 24  25  26  27  28  29  30
 31
Enter fullscreen mode Exit fullscreen mode

Six rows.

Again:

same dates
same month
different valid geometry
Enter fullscreen mode Exit fullscreen mode

The October calendar becomes a useful visual fixture for this specific boundary.

This kind of case deserves a named regression test.

For example:

test(
  "October 2027 expands to six rows Sunday-first",
  () => {
    const model =
      buildMonthModel(
        2027,
        10,
        "en-US"
      );

    expect(model.naturalRows)
      .toBe(6);
  }
);
Enter fullscreen mode Exit fullscreen mode

Named tests help preserve the reasoning behind unusual-looking cases.


26. Rendering every month should be boring

One of the best signs of a good calendar engine is that rendering the twelve months does not require twelve branches.

Ideally:

for (let month = 1; month <= 12; month++) {
  render(
    buildMonthModel(
      2027,
      month,
      locale
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

That should be enough.

The implementation should not need code like:

if (month === 2) {
  // February special case
}

if (month === 5) {
  // May needs six rows
}

if (month === 8) {
  // August alignment fix
}
Enter fullscreen mode Exit fullscreen mode

If ordinary Gregorian months require individual UI patches, the model is probably wrong.

The twelve visual references are therefore useful not just as calendar pages but as a compact acceptance matrix:

January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
and December.

A generic algorithm should explain all twelve.


27. The final engineering test

Before shipping a calendar component, ask whether the following changes can happen independently:

change locale
change time zone
change year
change month
change week start
change print layout
change screen width
change font
change adjacent-month policy
change fixed-row policy
Enter fullscreen mode Exit fullscreen mode

If changing one of those unexpectedly changes the underlying dates, there is architectural coupling.

A resilient system should be able to say:

these inputs affect the domain model
Enter fullscreen mode Exit fullscreen mode

and separately:

these inputs affect presentation
Enter fullscreen mode Exit fullscreen mode

That boundary is the difference between a calendar that merely works in today's screenshot and one that remains correct when product requirements evolve.


Final takeaway

The hardest part of calendar software is not drawing squares.

It is preserving meaning.

A civil date is not a timestamp.

A calendar day is not a fixed number of milliseconds.

A month is not always five rows.

A month is not always six rows.

Week start is not universal.

Week placement and week identity are not the same concept.

DST should not decide which cell contains tomorrow.

Print should not invent its own date model.

Accessibility should not depend on visual position.

And presentation normalization should not rewrite calendar truth.

Once those boundaries are explicit, calendar code becomes much easier to reason about.

Instead of asking:

Why is August broken?
Enter fullscreen mode Exit fullscreen mode

you can ask:

Which invariant failed?
Enter fullscreen mode Exit fullscreen mode

That is a much more powerful debugging question.

And that is the point where a calendar stops being "just a UI component" and starts being treated as what it actually is:

a small but surprisingly rich temporal system.

For visual month-by-month inspection alongside your own tests, the complete calendar set is available through Beta Calendars, including January, February, March, April, May, June, July, August, September, October, November, and December.

Top comments (1)

Collapse
 
karecohen profile image
Karen Cohen •

Thanks a lot, Mateo! Glad you found it interesting. I really enjoyed digging into the edge cases behind something that looks as simple as a calendar grid.