DEV Community

Karen Cohen
Karen Cohen

Posted on

Calendars Are State Machines in Disguise: Building a Deterministic Date Engine in JavaScript

Most calendar bugs are not caused by complicated algorithms.

They are caused by unclear boundaries.

A calendar UI looks simple:

  • previous month
  • next month
  • 7 columns
  • 28–31 numbered cells
  • maybe a selected date

But underneath that interface is a surprisingly rich state machine.

A user can cross month boundaries.

A month can cross year boundaries.

Weeks can begin on different days.

The same calendar may need to render on a screen, in print, inside a PDF, or in a mobile interface.

And if date math is mixed directly into the UI, every new feature increases the number of assumptions the application has to remember.

I wanted to explore a different way to think about calendar software:

Treat the calendar as a deterministic state machine first, and as a visual grid second.

That single change makes the architecture much easier to test.


1. Start with state, not HTML

Imagine the visible calendar currently shows March 2027.

The minimum state is surprisingly small:

const state = {
  year: 2027,
  month: 3
};
Enter fullscreen mode Exit fullscreen mode

That is enough to answer a large number of questions:

  • how many days are in the month?
  • which weekday contains day 1?
  • what comes before this month?
  • what comes after it?
  • how many leading cells are required?
  • how many rows are needed?

The UI should not be responsible for discovering those answers.

It should receive them.

This suggests an architecture like this:

User Action
    ↓
State Transition
    ↓
Calendar Model
    ↓
Renderer
Enter fullscreen mode Exit fullscreen mode

The renderer becomes almost boring.

That is a good thing.


2. Month navigation is a state transition

A common implementation of "next month" starts accumulating conditions:

if (month === 12) {
  month = 1;
  year++;
} else {
  month++;
}
Enter fullscreen mode Exit fullscreen mode

Then the same logic appears again for previous month.

Then somewhere else for a date picker.

Then again for a yearly calendar.

Instead, month navigation can be expressed as a single transformation.

function shiftMonth(year, month, amount) {
  const index = year * 12 + (month - 1) + amount;

  return {
    year: Math.floor(index / 12),
    month: ((index % 12) + 12) % 12 + 1
  };
}
Enter fullscreen mode Exit fullscreen mode

Now:

shiftMonth(2027, 3, 1);
Enter fullscreen mode Exit fullscreen mode

returns:

{
  year: 2027,
  month: 4
}
Enter fullscreen mode Exit fullscreen mode

And:

shiftMonth(2027, 1, -1);
Enter fullscreen mode Exit fullscreen mode

returns:

{
  year: 2026,
  month: 12
}
Enter fullscreen mode Exit fullscreen mode

Notice what disappeared.

There is no special January branch.

No special December branch.

Year boundaries are just a consequence of arithmetic.

That is one of my favorite characteristics of good date code:

Edge cases stop looking like edge cases.


3. Define invariants before writing more features

An invariant is something that must always remain true.

For a calendar month model, useful invariants might include:

1 <= month <= 12

28 <= daysInMonth <= 31

0 <= firstWeekday <= 6

grid.length % 7 === 0

every visible day belongs to the requested month
Enter fullscreen mode Exit fullscreen mode

These rules are more important than individual examples.

Why?

Because examples answer:

Does March 2027 work?

Invariants answer:

Can an entire class of invalid calendar states exist?

That distinction becomes powerful when testing.


4. Generate the month as data

Suppose we want a six-row calendar.

Instead of generating HTML directly, create a data structure.

function daysInMonth(year, month) {
  return new Date(
    Date.UTC(year, month, 0)
  ).getUTCDate();
}

function firstWeekday(year, month) {
  return new Date(
    Date.UTC(year, month - 1, 1)
  ).getUTCDay();
}
Enter fullscreen mode Exit fullscreen mode

Now build the cells:

function createMonthGrid(
  year,
  month,
  weekStartsOn = 0
) {
  const totalDays = daysInMonth(year, month);

  const first =
    (firstWeekday(year, month) - weekStartsOn + 7) % 7;

  return Array.from({ length: 42 }, (_, index) => {
    const day = index - first + 1;

    if (day < 1 || day > totalDays) {
      return {
        type: "empty"
      };
    }

    return {
      type: "day",
      year,
      month,
      day
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

The important part is not the 42 cells.

The important part is that this function knows absolutely nothing about:

  • HTML
  • React
  • Vue
  • CSS
  • printing
  • localization
  • animations

It returns calendar information.

Nothing else.


5. Why I prefer tagged cells over null

A simpler implementation might return:

[
  null,
  null,
  null,
  1,
  2,
  3
]
Enter fullscreen mode Exit fullscreen mode

That works.

But explicit data becomes more useful as the system grows.

For example:

{
  type: "day",
  year: 2027,
  month: 3,
  day: 14
}
Enter fullscreen mode Exit fullscreen mode

Later, the model can evolve naturally:

{
  type: "day",
  year: 2027,
  month: 3,
  day: 14,
  isToday: false,
  isSelected: true,
  events: []
}
Enter fullscreen mode Exit fullscreen mode

Meanwhile an empty cell remains:

{
  type: "empty"
}
Enter fullscreen mode Exit fullscreen mode

Now the renderer can switch on intent:

for (const cell of grid) {
  if (cell.type === "empty") {
    renderEmptyCell();
    continue;
  }

  renderDayCell(cell);
}
Enter fullscreen mode Exit fullscreen mode

This is much clearer than attaching meaning to magic values.


6. Don't let the renderer calculate dates

This is a boundary I try to keep strict.

Bad direction:

UI
├── calculates month length
├── calculates weekday offset
├── changes year
├── formats dates
└── renders cells
Enter fullscreen mode Exit fullscreen mode

Better direction:

Calendar Engine
├── calculates month length
├── calculates offset
├── handles transitions
└── produces model

Renderer
└── displays model
Enter fullscreen mode Exit fullscreen mode

The renderer should ideally be replaceable.

For example, the same model could become HTML:

function renderHTML(grid) {
  return grid.map(cell => {
    if (cell.type === "empty") {
      return `<div class="cell"></div>`;
    }

    return `
      <div class="cell">
        ${cell.day}
      </div>
    `;
  }).join("");
}
Enter fullscreen mode Exit fullscreen mode

Or plain text:

function renderText(grid) {
  return grid
    .map(cell =>
      cell.type === "day"
        ? String(cell.day).padStart(2, " ")
        : "  "
    )
    .join(" ");
}
Enter fullscreen mode Exit fullscreen mode

Or a printable layout.

The data model doesn't care.


7. This is where print architecture gets interesting

I ran into this distinction repeatedly while working around printable calendar systems and resources at JW Calendar.

Screen rendering and print rendering are related, but they are not the same problem.

A screen cares about:

viewport
interaction
responsive behavior
scrolling
dynamic resizing
Enter fullscreen mode Exit fullscreen mode

A printed page cares about:

physical dimensions
page orientation
margins
page breaks
print scaling
ink and contrast
Enter fullscreen mode Exit fullscreen mode

If the calendar engine produces neutral data, both environments can consume exactly the same month model.

That is much cleaner than making the date layer aware of A4 paper or CSS breakpoints.


8. Time zones should enter the system as late as possible

This is where many date-heavy applications become unnecessarily difficult.

Suppose the system needs to represent:

March 14, 2027
Enter fullscreen mode Exit fullscreen mode

If that value represents a calendar cell, it does not inherently need:

hours
minutes
seconds
UTC offset
timezone
Enter fullscreen mode Exit fullscreen mode

Those properties belong to different concepts.

Compare:

Calendar date:
2027-03-14

Meeting:
2027-03-14 at 09:00 America/New_York

Instant:
2027-03-14T13:00:00Z
Enter fullscreen mode Exit fullscreen mode

These values are related.

They are not interchangeable.

A useful architectural rule is:

Don't introduce time-zone semantics into date-only data unless the domain actually requires them.

It reduces the number of transformations the application has to perform.

It also makes testing much easier.


9. Property-based thinking is perfect for calendars

Calendar systems have a huge input space.

Testing a few hand-picked months is useful.

But properties are often more powerful.

For example:

for (let year = 1900; year <= 2100; year++) {
  for (let month = 1; month <= 12; month++) {
    const grid = createMonthGrid(year, month);

    if (grid.length !== 42) {
      throw new Error(
        `Invalid grid size: ${year}-${month}`
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now test the number of visible days:

for (let year = 1900; year <= 2100; year++) {
  for (let month = 1; month <= 12; month++) {
    const grid = createMonthGrid(year, month);

    const visibleDays = grid.filter(
      cell => cell.type === "day"
    );

    const expected = daysInMonth(year, month);

    if (visibleDays.length !== expected) {
      throw new Error(
        `Wrong day count: ${year}-${month}`
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Instead of testing twelve carefully selected calendars, we just tested 2,412 months.

That still runs almost instantly.


10. Test reversibility

A useful property of month navigation is reversibility.

If we move forward by one month and then backward by one month, we should return to the original state.

function assertSameMonth(a, b) {
  return (
    a.year === b.year &&
    a.month === b.month
  );
}
Enter fullscreen mode Exit fullscreen mode

Then:

for (let year = 1900; year <= 2100; year++) {
  for (let month = 1; month <= 12; month++) {
    const original = { year, month };

    const next = shiftMonth(year, month, 1);

    const back = shiftMonth(
      next.year,
      next.month,
      -1
    );

    if (!assertSameMonth(original, back)) {
      throw new Error(
        `Transition failure: ${year}-${month}`
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This catches an entire category of boundary bugs.

Especially:

December → January
January → December
Enter fullscreen mode Exit fullscreen mode

without writing separate tests for either transition.


11. Test composition

Month shifting should also compose correctly.

These two operations:

shiftMonth(2027, 3, 12);
Enter fullscreen mode Exit fullscreen mode

and:

shiftMonth(
  shiftMonth(2027, 3, 6).year,
  shiftMonth(2027, 3, 6).month,
  6
);
Enter fullscreen mode Exit fullscreen mode

should produce the same result.

More generally:

shift(a + b)
=
shift(a), then shift(b)
Enter fullscreen mode Exit fullscreen mode

That gives us another property to verify.

function shiftTwice(year, month, a, b) {
  const first = shiftMonth(year, month, a);

  return shiftMonth(
    first.year,
    first.month,
    b
  );
}
Enter fullscreen mode Exit fullscreen mode

Then compare:

const direct = shiftMonth(2027, 3, 17);

const composed = shiftTwice(
  2027,
  3,
  8,
  9
);
Enter fullscreen mode Exit fullscreen mode

If the transition system is correct:

assertSameMonth(direct, composed);
Enter fullscreen mode Exit fullscreen mode

should always be true.

This is the point where calendar code starts feeling less like UI programming and more like a small algebra.


12. A reducer makes the state machine explicit

Once interactions become more complex, a reducer can make transitions easy to reason about.

function calendarReducer(state, action) {
  switch (action.type) {
    case "NEXT_MONTH":
      return shiftMonth(
        state.year,
        state.month,
        1
      );

    case "PREVIOUS_MONTH":
      return shiftMonth(
        state.year,
        state.month,
        -1
      );

    case "NEXT_YEAR":
      return shiftMonth(
        state.year,
        state.month,
        12
      );

    case "PREVIOUS_YEAR":
      return shiftMonth(
        state.year,
        state.month,
        -12
      );

    case "GO_TO_MONTH":
      return {
        year: action.year,
        month: action.month
      };

    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now every user interaction becomes an explicit state transition.

state = calendarReducer(state, {
  type: "NEXT_MONTH"
});
Enter fullscreen mode Exit fullscreen mode

That makes debugging much easier.

Instead of asking:

Which click handler changed this value?

you can ask:

Which action moved the machine into this state?


13. Invalid states should be difficult to represent

This principle has become increasingly important to me.

If the calendar expects months from 1 to 12, allowing this:

{
  year: 2027,
  month: 938
}
Enter fullscreen mode Exit fullscreen mode

through the entire application is unnecessary risk.

Validate at the boundary:

function createCalendarState(year, month) {
  if (!Number.isInteger(year)) {
    throw new TypeError("year must be an integer");
  }

  if (
    !Number.isInteger(month) ||
    month < 1 ||
    month > 12
  ) {
    throw new RangeError(
      "month must be between 1 and 12"
    );
  }

  return {
    year,
    month
  };
}
Enter fullscreen mode Exit fullscreen mode

After that point, internal functions can operate under stronger assumptions.

This reduces defensive code everywhere else.

Instead of checking the same condition in ten components, check it once at the system boundary.


14. Separate domain state from interface state

Not every piece of UI state belongs inside the calendar model.

For example:

{
  year: 2027,
  month: 3,
  sidebarOpen: true,
  animationDirection: "left",
  modalVisible: false
}
Enter fullscreen mode Exit fullscreen mode

mixes unrelated concerns.

A cleaner distinction is:

const calendarState = {
  year: 2027,
  month: 3
};
Enter fullscreen mode Exit fullscreen mode

and:

const interfaceState = {
  sidebarOpen: true,
  modalVisible: false
};
Enter fullscreen mode Exit fullscreen mode

Why does this matter?

Because the calendar engine should still work if tomorrow you delete the sidebar completely.

Domain state survives interface redesigns.

UI state does not.


15. Determinism is an underrated feature

Given the same inputs:

createMonthGrid(2027, 3, 1);
Enter fullscreen mode Exit fullscreen mode

should always return the same output.

No browser locale.

No current clock.

No hidden timezone.

No DOM state.

No network request.

That property is extremely valuable.

A deterministic calendar model is:

  • easy to cache
  • easy to test
  • easy to serialize
  • easy to render on a server
  • easy to reproduce during debugging
  • easy to reuse in different interfaces

If a user reports:

March 2027 renders incorrectly with Monday-first weeks

you only need three values to reproduce it:

year = 2027
month = 3
weekStartsOn = 1
Enter fullscreen mode Exit fullscreen mode

That is a very nice debugging surface.


16. Avoid reading "now" inside pure calendar functions

Consider this:

function createCell(day) {
  const today = new Date();

  return {
    day,
    isToday:
      day === today.getDate()
  };
}
Enter fullscreen mode Exit fullscreen mode

Now the function is time-dependent.

Its output changes even when its explicit input doesn't.

That makes testing harder.

Instead, inject the reference date:

function createCell(
  year,
  month,
  day,
  today
) {
  return {
    year,
    month,
    day,
    isToday:
      year === today.year &&
      month === today.month &&
      day === today.day
  };
}
Enter fullscreen mode Exit fullscreen mode

Now:

createCell(
  2027,
  3,
  14,
  {
    year: 2027,
    month: 3,
    day: 14
  }
);
Enter fullscreen mode Exit fullscreen mode

is deterministic.

The current clock belongs at the boundary of the system.

Not buried inside the model.


17. The final architecture is intentionally boring

After stripping away framework-specific details, the design becomes:

INPUT
  │
  ↓
VALIDATION
  │
  ↓
CALENDAR STATE
  │
  ↓
STATE TRANSITIONS
  │
  ↓
MONTH MODEL
  │
  ├── WEB RENDERER
  ├── MOBILE RENDERER
  ├── PRINT RENDERER
  └── PDF RENDERER
Enter fullscreen mode Exit fullscreen mode

Each layer has one job.

Validation prevents invalid state.

Transitions modify state.

The month model derives calendar data.

Renderers decide how that data looks.

None of those layers need to know everything about the others.

And that is probably the biggest lesson I have taken from building date-heavy interfaces:

Complexity becomes manageable when responsibilities stop leaking across boundaries.


Final thought

A calendar is visually simple enough that it invites shortcuts.

That is exactly what makes it interesting.

The interface encourages us to think:

It's just seven columns.

But underneath those seven columns are:

  • state transitions
  • arithmetic
  • invariants
  • regional configuration
  • deterministic modeling
  • boundary testing
  • accessibility
  • print rendering
  • timezone semantics
  • serialization
  • validation

The surprising part is that none of these require a complicated architecture.

In fact, the more complicated the requirements become, the more valuable a small deterministic core becomes.

The best calendar engine may be the one that knows almost nothing about calendars as a user interface.

It only knows how calendar state behaves.

And everything else is presentation.

What other "simple" UI component have you worked on that turned out to hide a much deeper domain model?

Top comments (0)