@phrontax/calendar
v1.4.0
Published
Dual Gregorian and Bikram Sambat date engine and React date pickers
Maintainers
Readme
@phrontax/calendar
Gregorian and Bikram Sambat dates in one package: a zero-dependency conversion engine plus React pickers that show both calendars at once.
Built for PhronTax, where a Nepali business books in Gregorian dates but files to the Inland Revenue Department in Bikram Sambat.
pnpm add @phrontax/calendarEngine
No React, no dependencies — usable from a browser, a server or a worker.
import { adToBs, bsToAd, cbmsDate, cbmsFiscalYear, nepaliFiscalYear } from "@phrontax/calendar";
adToBs("2026-08-31"); // { year: 2083, month: 5, day: 15 }
bsToAd({ year: 2083, month: 5, day: 15 }); // { year: 2026, month: 8, day: 31 }
cbmsDate("2026-08-31"); // "2083.05.15" the IRD's dotted form
cbmsFiscalYear("2026-08-31"); // "2083.084"
nepaliFiscalYear("2026-08-31");
// { startBsYear: 2083, startsOn: "2026-07-17", endsOn: "2027-07-16",
// label: "2083/84", cbms: "2083.084" }The Nepali fiscal year runs Shrawan 1 to the last day of Ashadh, so it turns over in mid-July rather than at either new year.
Pickers
import { DatePicker, DateRangePicker } from "@phrontax/calendar/react";
import "@phrontax/calendar/styles.css";
<DatePicker value={issueDate} onChange={setIssueDate} min={period.startsOn} />
<DateRangePicker value={{ from, to }} onChange={setRange} />value is always a Gregorian ISO YYYY-MM-DD string, whichever calendar the
grid is showing. That makes DatePicker a drop-in replacement for
<input type="date"> with no change to what a form submits.
Both pickers give you the AD grid with each day's Bikram Sambat number beneath
it, an AD/BS toggle, month and year jump menus, typed entry in either calendar
(2083.05.15 is accepted), min/max bounds, and the full ARIA keyboard model
— arrows, Home/End, PageUp/PageDown, Enter, Escape.
DateRangePicker adds presets, including This fiscal year, which resolves to
the Nepali fiscal year rather than the Gregorian one.
Options
Both pickers take these, and pass them to the panel:
| Prop | Does |
| --- | --- |
| locale | "en", "ne", or a locale of your own (below) |
| firstDayOfWeek | 0 Sunday (the default, as Nepal writes it), 1 Monday, … |
| disabledDate | (date: PlainDate) => boolean — holidays, weekends, closed periods |
| yearRange | which years the dropdown offers, per calendar: { ad: { from, to } } |
| showCalendarToggle | hide the AD/BS switch when the form has settled the question |
| showToday | hide the Today button |
| min / max | inclusive Gregorian ISO bounds |
DateRangePicker also takes presets (replacing the Nepali defaults),
showPresets, fromLabel / toLabel, and displayStyle / displayPattern
for how the two chosen dates read in the closed control. Whatever they render,
the value it reports stays Gregorian ISO.
Languages
"en" and "ne" are built in. Anything else is data — month and weekday names,
digits, which day the week starts on, how a date reads, and every word the
pickers say:
import { extendLocale } from "@phrontax/calendar";
// Nepali words, Monday weeks, nothing else retyped.
const locale = extendLocale("ne", { firstDayOfWeek: 1 });
// Or disagree with the romanisation, which people do: Ashwin and Ashoj are the
// same month, and so are Baisakh and Baishakh.
const ashoj = extendLocale("en", {
bsMonths: ["Baishakh", "Jestha", "Ashar", "Shrawan", "Bhadra", "Ashoj",
"Kartik", "Mangsir", "Poush", "Magh", "Falgun", "Chaitra"],
patterns: { medium: "MMM D, YYYY" },
messages: { today: "Aaja" },
});Anything a locale leaves out falls back to English, so wanting Monday weeks and
nothing else is locale={{ firstDayOfWeek: 1 }}. Digits follow the locale's
digits rather than its name, so Devanagari numerals with English month names —
how a good deal of Nepal writes a date — is a locale you can describe rather
than a special case the package has to bless.
Formatting
formatDate takes a style ("iso", "medium", "long") or a pattern, and
formatPattern takes one directly. Tokens are YYYY YY MMMM MMM MM M DD D;
anything else is copied through, and text in square brackets is passed through
untouched.
formatDate("2026-08-31", { calendar: "bs", locale: "ne", style: "long" });
// "१५ भदौ २०८३"
formatPattern({ year: 2083, month: 5, day: 15 }, "[BS] YYYY.MM.DD"); // "BS 2083.05.15"One calendar, or both
Every cell carries both readings by default — the Gregorian day large, the
Bikram Sambat day beneath it — which is what an operator filing to the IRD
needs. A business that works in one calendar and reads the other nowhere can
turn the second number off, and defaultCalendar decides which one is left:
// Gregorian only: no Bikram Sambat day, no BS badge in the field.
<DatePicker value={date} onChange={setDate}
showSecondaryDates={false} showCalendarToggle={false} showBsHint={false} />
// Bikram Sambat only: the grid, the heading and the months are all BS.
<DatePicker value={date} onChange={setDate} defaultCalendar="bs"
showSecondaryDates={false} showCalendarToggle={false} />The field reads in whichever calendar you ask for, in whichever form:
<DatePicker value={date} onChange={setDate} displayCalendar="bs" />
// 2083.07.16 — the form the IRD's own documents use, and the default for BS
<DatePicker value={date} onChange={setDate} displayCalendar="bs"
displayPattern="YYYY-MM-DD" /> // 2083-07-16
<DatePicker value={date} onChange={setDate} displayPattern="YYYY/MM/DD" />
// 2026/11/02
<DatePicker value={date} onChange={setDate} displayCalendar="bs"
displayPattern="D MMMM YYYY" /> // 16 Kartik 2083The pattern takes YYYY YY MMMM MMM MM M DD D and [literals] in brackets —
the same tokens as formatPattern, and the same prop name the range picker has
always had. The badge beside the field carries whichever calendar the field is
not showing, so one reading is always a glance away.
The stored value never changes shape. It is Gregorian ISO on the wire
whatever is on screen, so a Bikram Sambat picker is a reading of the same field
and not a different one — nothing downstream has to know which form an operator
prefers. Typing follows the panel's calendar, then the field's, then Gregorian,
so a bare 2083.05.15 means Bikram Sambat to someone working in it and an ISO
date pasted from elsewhere is still accepted. A pattern with month names
displays correctly but cannot be typed back; the field restores rather than
guesses.
Time, and time zones
A date is a day — the same day everywhere. A moment is not, and reading one
means choosing a clock. DateTimePicker keeps those apart: it stores one ISO
instant and reads it in the zone you give it.
import { DateTimePicker } from "@phrontax/calendar/react";
<DateTimePicker
value={countedAt} // "2026-09-30T08:45:00.000Z"
onChange={setCountedAt}
timeZone="Asia/Kathmandu" // defaults to the viewer's own
/>Pass the organisation's zone where the time belongs to the business rather than to whoever is looking: a stocktake happened at 14:30 in Kathmandu whether it is read there or in London, and letting the browser decide makes one record say two different things.
The panel carries the month and the clock together: hour and minute columns sit
beside the grid, showing the values either side of the chosen one, which is how
people read a clock. step thins the minute column — step={15} offers the
quarters — and a stored reading that falls between them is still shown, because
a column that silently disagreed with the field above it would be worse than a
long one. Typing stays the faster path for anyone entering a run of records: the
field takes 2026-09-30 14:30 whole.
The engine is usable on its own, and has no dependencies — the zone database is the runtime's:
import { endOfDayInstant, instantToZoned, zonedToInstant } from "@phrontax/calendar";
endOfDayInstant("2026-09-30", "Asia/Kathmandu"); // "2026-09-30T18:14:59.999Z"
endOfDayInstant("2026-09-30", "UTC"); // "2026-09-30T23:59:59.999Z"That difference is the point. Appending T23:59:59.999Z to a date is end of day
in Greenwich; in Kathmandu it is a quarter to six the following morning, and in
Honolulu it is the middle of the same afternoon.
Daylight saving is resolved at the instant, not from a table, so a whole-hour zone and a thirty-minute one behave alike. A time the clocks skipped resolves forward past the gap; a time they repeated resolves to its first occurrence — the same rule the Temporal proposal uses.
Theming
Every colour is a CSS custom property; redefine them on any ancestor.
.my-app { --pcal-accent: #185c43; --pcal-radius: 10px; }The full set: --pcal-bg, --pcal-fg, --pcal-muted, --pcal-line,
--pcal-accent, --pcal-accent-fg, --pcal-accent-fg-soft, --pcal-hover,
--pcal-today, --pcal-range-bg, --pcal-shadow, --pcal-radius,
--pcal-font. Every rule in the stylesheet carries two classes, so a host form
styling .some-form select does not tie with it and win on load order.
Supported range
BS 2000–2099 (AD 1943-04-14 to 2043-04-12), in two halves:
| Range | Status | | --- | --- | | BS 2000–2083 | Verified. Four independently published tables agree year by year. | | BS 2084–2099 | Projected. Converts and displays, but not corroborated. |
Bikram Sambat month lengths follow no formula — they are fixed each year by the Nepal Panchanga Nirnayak Samiti from the sun's transit between zodiac signs. Past 2083 the published tables diverge, and every source reaching that far derives it by assuming the calendar repeats on a roughly nineteen-year cycle rather than from an almanac. Two of the four candidate tables are also arithmetically impossible within a few years of 2085 — a BS year cannot have 364, 367 or 368 days — and were discarded.
So projected years convert, but they never reach a tax filing:
adToBs("2030-06-01"); // { year: 2087, ... } — works
isProvisionalBsDate("2030-06-01"); // true
cbmsDate("2030-06-01"); // undefined — refuses
cbmsFiscalYear("2030-06-01"); // undefined — refusesDatePicker shows a projected date with a caveat rather than a CBMS date.
Outside the table entirely, conversion returns undefined and the pickers show
the Gregorian date alone. Nothing is ever extrapolated silently.
The table is guarded by tests across all 100 years: every year sums to 365 or 366, every month is 29–32 days, Nepali New Year falls on 13 or 14 April, and every one of ~36,500 days round-trips AD → BS → AD.
Contributing
This repository is a read-only mirror. The package is developed alongside the application it was built for, in a private repository, and this mirror is regenerated from it at each release — so a pull request opened here cannot be merged and would be overwritten by the next release.
Issues are the way in, and they are read: bug reports, a month name your part of Nepal spells differently, a locale you had to fight the API to describe. Attach a patch to the issue if you have one and it will be applied with credit in the changelog.
Building it yourself needs nothing from the private repository — it has no workspace dependencies and its TypeScript configuration stands alone:
npm install && npm test && npm run buildLicence
Apache-2.0 © Phronetic Labs Pvt. Ltd. See LICENSE and NOTICE.
