Vietnamese lunar calendar in JavaScript: why 5 test dates weren’t enough

Why a popular lunar library disagrees with the Vietnamese calendar on 3.48% of days (UTC+8 vs UTC+7), found by comparing 40,542 days instead of 5.

· Published · Updated · 8 min read · Versions: vietnamese-date 1.1 · About the project →

Showing the lunar date in an app sounds simple: find a library, call a function, display "9th day of the 7th month". When adding the Vietnamese lunar calendar to Personal OS, a personal time-management app, the most interesting part turned out to be choosing the library, because of something easy to miss: a library can be right on every date you test and still be wrong for Vietnam.

The Vietnamese lunar calendar isn't the Chinese one

The lunisolar calendar is astronomical: the first day of a month is the day of the new moon, and leap months are inserted based on the solar terms. Both depend on when an astronomical event happens, and therefore on the time zone used to compute it.

Vietnam computes its lunar calendar in Hanoi time (UTC+7), China in Beijing time (UTC+8). When a new moon falls between 23:00 and midnight in Hanoi, it's already the next day in Beijing, and the two countries start the month on different days. The best-known example: for the Lunar New Year of 2007, Vietnam celebrated Tết on February 17, China on February 18.

Timeline of one new moon that happens between 23:00 and midnight Hanoi time: on the Hanoi clock (UTC+7) it falls on day N, while on the Beijing clock (UTC+8) it is already after midnight, on day N+1, so the two calendars start the month on different days.HanoiUTC+7day Nday N+122:0023:0000:0001:0002:00BeijingUTC+8day Nday N+123:0000:0001:0002:0003:00new moonHanoi: the new moon is on day N, so the month starts on day NBeijing: it is already day N+1, so the month starts a day later
The same instant falls on different dates in Hanoi and Beijing, and the month starts on the date of the new moon.

Most of the time the two calendars agree. That's exactly what makes the differences hard to spot.

Testing 5 dates: all correct

I looked at the popular JavaScript libraries:

Library Time zone Has Lacks
vietnamese-date UTC+7 Lunar date, stems and branches, auspicious hours, TypeScript Auspicious days, solar terms, holidays
@nghiavuive/lunar_date_vi — Stems and branches, solar terms, auspicious hours Auspicious days, holidays
lunar-javascript UTC+8 Nearly everything: auspicious days and hours, the 12 day officers, do's and don'ts, solar terms, festivals Output in Chinese, no types, ~109 KB gzipped

lunar-javascript has every feature, so I tested it on 5 dates: Tết 2025, Tết 2026, the first full moon of 2026, the Mid-Autumn Festival, and the leap second month of 2023. All 5 matched the Vietnamese calendar. The plan was to use it, with a table translating its Chinese output into Sino-Vietnamese.

Scanning 40,542 days: 3.48% wrong

Five lucky matches don't prove much, so I wrote a script comparing the two libraries on every day from 1990 to 2100, 40,542 days in all:

Comparison Result
Lunar day/month: lunar-javascript (UTC+8) vs vietnamese-date (UTC+7) 3.48% differ, about 12 days a year on average; some years even disagree on the leap month (1995, for example)
Day stem-branch No differences
Auspicious/inauspicious days 16% differ, even on days where the lunar month agrees

Three results, each with its own cause:

  • Lunar dates differ because of the time zone, as explained above.
  • The day's stem-branch never differs, because it's simply a 60-day cycle counted continuously from the Julian day number, independent of astronomy and time zones.
  • Auspicious days differ by 16% for an entirely different reason: lunar-javascript assigns the 12 day officers by solar term (the Chinese method), while common Vietnamese calendars assign them by lunar month, following the method in Phan Kế Bính's book on Vietnamese customs.

The first 5 dates caught none of this, because festival dates rarely land on the boundary days.

The fix: a library for the astronomy, data tables for the culture

The final design splits the work in two:

Astronomy comes from a library; don't write it yourself. vietnamese-date (based on Hồ Ngọc Đức's algorithm, computed in UTC+7, about 4 KB gzipped) handles the lunar day, month and year, leap months, stems and branches, and auspicious hours. Astronomical algorithms are easy to get subtly wrong, and there's no reason to rewrite one.

Culture comes from small, verified data tables. No Vietnamese JavaScript library has auspicious days or lunar holidays, so I added them as fixed data, not algorithms:

  • The 12 day officers: Thanh Long, Minh Đường, Thiên Hình, Chu Tước, Kim Quỹ, Thiên Đức, Bạch Hổ, Ngọc Đường, Thiên Lao, Huyền Vũ, Tư Mệnh, Câu Trần. Six are auspicious: Thanh Long, Minh Đường, Kim Quỹ, Thiên Đức, Ngọc Đường, Tư Mệnh.
  • Where Thanh Long starts, by lunar month: months 1 and 7 start on a Tý day, 2 and 8 on Dần, 3 and 9 on Thìn, 4 and 10 on Ngọ, 5 and 11 on Thân, 6 and 12 on Tuất.
  • From there: officer = (day's branch − month's starting branch) mod 12.

Simplified from lunar.ts:

const godIndex = (((dayBranch - startBranch[lunarMonth]) % 12) + 12) % 12
const isAuspicious = AUSPICIOUS[godIndex]

The extra + 12 and second % 12 are there because in JavaScript -3 % 12 is -3, not 9.

  • Lunar holidays are a lookup by month × 100 + day: Tết (1/1), the first full moon (15/1), the Cold Food Festival (3/3), the Hùng Kings' Commemoration (10/3), Buddha's Birthday (15/4), the Double Fifth (5/5), Vu Lan (15/7), Mid-Autumn (15/8) and the Kitchen Gods' day (23/12).
  • Solar terms are the only place with a formula: compute the sun's longitude at midnight Vietnam time for that day; every 15° is one term.

Do's and don'ts (activities considered favorable or unfavorable on a given day) I left out. Schools of thought differ, there's no reliable data source for the Vietnamese tradition, and I didn't want to make up a list that merely looks right.

A few details when wiring it into the app

Personal OS keeps its data in the browser, and the lunar calendar is computed on the device too, which is why bundle size mattered when choosing the library. (How the app stores and searches its activity library in IndexedDB is in a large tree in IndexedDB with RxDB.) Three details came up when wiring it in:

  • Avoid off-by-one days from the device's time zone. The app stores days as local YYYY-MM-DD strings. To convert, I build a Date at noon of that day rather than midnight. Midnight is the danger zone: any time zone shift pushes it into the previous day.
  • Memoize by day. The week view asks for 7 days, again on every re-render. A Map keyed by the day string is enough.
  • Regression tests for the verified dates, for the 12 officers' defining rule (a Tý day in the first month must be Thanh Long), and for results not depending on the time of day. If a library upgrade ever shifts the calendar, the tests go red.

What this setup doesn't cover

The calendar now shows the right lunar dates for Vietnam, but a few things are outside what it does:

  • Do's and don'ts aren't there at all, for the reasons above. Anyone who needs them has to pick a school of thought and a source first.
  • Auspicious days follow one convention, the lunar-month method. Someone who follows the solar-term method will disagree on about 16% of days, and each side is consistent with its own rules.
  • The holidays are a fixed list of nine lunar dates. Anything else has to be added to the table by hand.
  • The scan compares two libraries, so it shows where they disagree and why, not which one is right on each day. My trust in vietnamese-date rests on Hồ Ngọc Đức's algorithm computed in UTC+7 and on the dates I checked against the Vietnamese calendar, and the scan only covers 1990 to 2100.

Summary

The lesson isn't really about lunar calendars. It's that "right on the cases I tried" is far from "right", especially when the errors only show up in rare edge cases.

  • Vietnam computes its lunar calendar in UTC+7 and China in UTC+8, so a library built on the Chinese calendar gives a different lunar date on 3.48% of days from 1990 to 2100, about 12 days a year.
  • Five festival dates all matched, because they rarely land on boundary days. A script comparing all 40,542 days found the differences in seconds.
  • Auspicious days can differ even when the lunar month agrees: Vietnamese calendars assign the 12 day officers by lunar month, the Chinese method by solar term.
  • Take the astronomy from a library (vietnamese-date) and the culture from small, verified data tables (day officers, holidays).
  • Build a Date at noon rather than midnight so a time zone shift can't push it into the previous day, and pin the verified dates in regression tests.
  • Leave out what has no reliable source, such as do's and don'ts, rather than inventing data that only looks right.

If you're working with the Vietnamese lunar calendar too, Hồ Ngọc Đức's article (an Internet Archive copy, since the original page is no longer online) on the algorithm is the foundation of most Vietnamese lunar libraries today. And if you'd like to talk it through, my email is on the home page.