I thought an age/zodiac calculator would be mostly date arithmetic until I got to East Asian nominal age (虛歲). That's the part where the obvious shortcut — currentYear - birthYear + 1 — looks right just often enough to fool you.
The real problem is that both nominal age and the Chinese zodiac roll over on Lunar New Year, not on January 1, and definitely not on your birthday. If someone is born near late January or early February, the naive formula is wrong in exactly the cases people care about most.
The whole thing hinges on a real Chinese New Year table
The Vue component doesn't try to "calculate" Lunar New Year from first principles. It ships a lookup table for 1930–2050 and treats that as the source of truth:
const LUNAR_NEW_YEAR = {
2023: "01-22", 2024: "02-10", 2025: "01-29", 2026: "02-17",
// ...every year from 1930 to 2050
};
function getCny(year) {
const raw = LUNAR_NEW_YEAR[year];
if (!raw) return null;
const [m, d] = raw.split("-").map(Number);
return { m, d };
}
function lunarYearLabel(year, month, day) {
const cny = getCny(year);
if (!cny) return null;
if (month > cny.m || (month === cny.m && day >= cny.d)) {
return year;
}
return year - 1;
}
That lunarYearLabel() helper is the key idea. A Gregorian date before that year's Lunar New Year still belongs to the previous lunar year label. So 2025-01-28 is still lunar year 2024, while 2025-01-29 flips to lunar year 2025. That's why nominal age is not just age + 1.
One lunar-year label powers both nominal age and zodiac
What I liked here is that the component doesn't implement separate edge-case logic for nominal age and zodiac. It computes the same lunar-year label for the birth date and for "today," then reuses that everywhere:
const nominalAgeInfo = computed(() => {
const birthLabel = lunarYearLabel(by, bm, bd);
const todayLabel = lunarYearLabel(ty, tm, td);
return { age: todayLabel - birthLabel + 1, mode: "precise" };
});
And for the zodiac:
let label = by;
if (hasFullDate.value && tableReady) {
const computedLabel = lunarYearLabel(by, bm, bd);
if (computedLabel !== null) {
label = computedLabel;
mode = "precise";
}
}
const idx = (((label - ZODIAC_REF_YEAR) % 12) + 12) % 12;
const key = zodiacKeys[idx];
That shared helper prevents a nasty class of bugs where your nominal age says one thing but your zodiac silently uses Jan 1 as the boundary and says another. The code also generates a visible zodiac range from one Lunar New Year to the day before the next one, which is a nice way to make the boundary explicit instead of hiding it behind a single animal name.
Star signs are simpler, except Capricorn crosses the year boundary
Western zodiac logic is much more boring — which is good — but there is still one non-obvious branch. Most signs live entirely inside the same calendar year, while Capricorn runs from December 22 to January 19:
function findWesternZodiac(bm, bd) {
for (const z of westernZodiacTable) {
const [sm, sd] = z.start;
const [em, ed] = z.end;
if (sm <= em) {
if ((bm === sm && bd >= sd) || (bm > sm && bm < em) || (bm === em && bd <= ed)) {
return z;
}
} else {
// Capricorn: 12/22 ~ 1/19
if ((bm === sm && bd >= sd) || (bm === em && bd <= ed)) {
return z;
}
}
}
return null;
}
Without that sm <= em split, December/January signs get awkward fast. This is also why the tool requires a full month/day for star signs, while age and zodiac can fall back to rougher estimates from year-only input.
The gotchas are real
A couple of details in the code are honestly worth surfacing:
- The "precise" Lunar New Year logic only works inside the built-in 1930–2050 table. Outside that range, the component deliberately falls back to
year difference + 1and shows a warning instead of pretending to know more than it does. - Leap-day birthdays are tricky. The birthday countdown uses
new Date(currentYear, bm - 1, bd), so a Feb 29 birthday in a non-leap year normalizes to March 1 in JavaScript. - The day input is constrained by a computed
maxDayInMonth, with leap years handled explicitly, so the UI won't let you keep something like February 31 selected after changing month/year.
I turned this into a small free tool if you want to inspect the behavior without reimplementing the lunar-year edge cases yourself: Age / East Asian Age / Chinese Zodiac / Star Sign Calculator.
Available in other languages
- 年齡/虛歲/生肖/星座即時計算機 — 繁體中文
- 年龄/虚岁/生肖/星座即时计算器 — 简体中文
- Age / East Asian Age / Chinese Zodiac / Star Sign Calculator — English
- 年齢/数え年/干支/星座 リアルタイム計算機 — 日本語
- 나이/세는나이/십이지신(띠)/별자리 실시간 계산기 — 한국어
- Calculateur d'Âge / Âge Asiatique / Zodiaque Chinois / Signe Astrologique — Français
- Калькулятор возраста / восточноазиатского возраста / китайского зодиака / знака зодиака — Русский
- Alter / Ostasiatisches Alter / Chinesisches Tierkreiszeichen / Sternzeichen Rechner — Deutsch
- Kalkulator Usia / Usia Asia Timur / Shio / Zodiak Real-Time — Bahasa Indonesia
- Calculadora de Edad / Edad Asiática / Zodiaco Chino / Signo del Horóscopo — Español
- Máy Tính Tuổi Thật/Tuổi Âm/Con Giáp/Cung Hoàng Đạo Trực Tiếp — Tiếng Việt
- เครื่องคำนวณอายุ/อายุจีน/นักษัตรจีน/ราศี แบบเรียลไทม์ — ไทย
- Kalkulator Wieku / Wieku Wschodnioazjatyckiego / Chińskiego Zodiaku / Znaku Zodiaku — Polski
- Yaş / Doğu Asya Yaşı / Çin Burcu / Batı Burcu Hesaplayıcı — Türkçe
- Calcolatrice Età / Età Est-Asiatica / Zodiaco Cinese / Segno Zodiacale — Italiano
- Calculadora de Idade / Idade Asiática / Zodíaco Chinês / Signo do Zodíaco — Português
- Leeftijd / Oost-Aziatische Leeftijd / Chinese Dierenriem / Sterrenbeeld Calculator — Nederlands
- Калькулятор віку / східноазійського віку / китайського зодіаку / знаку зодіаку — Українська
Top comments (0)