@nepdatejs/core 0.0.0-bootstrap → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The Bikram Sambat calendar, from its table only. Nothing here reads or
3
+ * produces an AD year: BS dates map to and from day counts (EpochDay) through
4
+ * `epochStart` and the month lengths (docs/plan D-001).
5
+ */
6
+ import { NepDateRangeError } from '../errors';
7
+ import type { BsData } from '../types';
8
+ import { BS_DATA } from './data/years';
9
+
10
+ interface BsTable {
11
+ readonly data: BsData;
12
+ readonly lastYear: number;
13
+ /** yearStart[i] = day count of 1 Baisakh of firstYear + i; one extra entry for the end. */
14
+ readonly yearStart: Float64Array;
15
+ /** monthOffset[i * 12 + m] = days from 1 Baisakh to the first of month m + 1 in year i. */
16
+ readonly monthOffset: Uint16Array;
17
+ }
18
+
19
+ let data: BsData = freezeData(BS_DATA);
20
+ let table: BsTable | null = null;
21
+
22
+ /** Replace the data the table is built from. Callers validate first (data-api.ts). */
23
+ export function useBsData(next: BsData): void {
24
+ data = freezeData(next);
25
+ table = null;
26
+ }
27
+
28
+ function freezeData(source: BsData): BsData {
29
+ return Object.freeze({
30
+ firstYear: source.firstYear,
31
+ epochStart: source.epochStart,
32
+ verifiedThrough: source.verifiedThrough,
33
+ years: Object.freeze(source.years.map((months) => Object.freeze([...months]))),
34
+ });
35
+ }
36
+
37
+ export function currentBsData(): BsData {
38
+ return data;
39
+ }
40
+
41
+ function buildTable(): BsTable {
42
+ const count = data.years.length;
43
+ const yearStart = new Float64Array(count + 1);
44
+ const monthOffset = new Uint16Array(count * 12);
45
+ let start = data.epochStart;
46
+
47
+ for (let i = 0; i < count; i++) {
48
+ yearStart[i] = start;
49
+ const months = data.years[i] ?? [];
50
+ let offset = 0;
51
+ for (let m = 0; m < 12; m++) {
52
+ monthOffset[i * 12 + m] = offset;
53
+ offset += months[m] ?? 0;
54
+ }
55
+ start += offset;
56
+ }
57
+ yearStart[count] = start;
58
+
59
+ return { data, lastYear: data.firstYear + count - 1, yearStart, monthOffset };
60
+ }
61
+
62
+ function bsTable(): BsTable {
63
+ return (table ??= buildTable());
64
+ }
65
+
66
+ export function bsFirstYear(): number {
67
+ return data.firstYear;
68
+ }
69
+
70
+ export function bsLastYear(): number {
71
+ return bsTable().lastYear;
72
+ }
73
+
74
+ /** The first and last day counts the table covers. */
75
+ export function bsEpochRange(): { first: number; last: number } {
76
+ const { yearStart } = bsTable();
77
+
78
+ return { first: yearStart[0] ?? 0, last: (yearStart[yearStart.length - 1] ?? 0) - 1 };
79
+ }
80
+
81
+ /** Days in a BS month. Throws when the year is outside the table or the month is not 1–12. */
82
+ export function bsMonthLength(year: number, month: number): number {
83
+ const { lastYear } = bsTable();
84
+ if (!Number.isInteger(year) || year < data.firstYear || year > lastYear) {
85
+ throw new NepDateRangeError(
86
+ 'OUT_OF_TABLE',
87
+ `BS year ${String(year)} is outside the supported range (BS ${data.firstYear}–${lastYear}).`,
88
+ );
89
+ }
90
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
91
+ throw new NepDateRangeError(
92
+ 'INVALID_DATE',
93
+ `BS month ${String(month)} does not exist.`,
94
+ );
95
+ }
96
+
97
+ return data.years[year - data.firstYear]?.[month - 1] ?? 0;
98
+ }
99
+
100
+ export function isValidBsDate(year: number, month: number, day: number): boolean {
101
+ try {
102
+ return Number.isInteger(day) && day >= 1 && day <= bsMonthLength(year, month);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /** Day count of a valid BS date. */
109
+ export function bsToEpoch(year: number, month: number, day: number): number {
110
+ const { yearStart, monthOffset } = bsTable();
111
+ const index = year - data.firstYear;
112
+
113
+ return (yearStart[index] ?? 0) + (monthOffset[index * 12 + month - 1] ?? 0) + day - 1;
114
+ }
115
+
116
+ /** The BS year, month and day of a day count. Throws when it is outside the table. */
117
+ export function epochToBs(epoch: number): { year: number; month: number; day: number } {
118
+ const { yearStart, monthOffset } = bsTable();
119
+ const { first, last } = bsEpochRange();
120
+ if (epoch < first || epoch > last) {
121
+ throw new NepDateRangeError(
122
+ 'OUT_OF_TABLE',
123
+ `Day ${epoch} is outside the BS table (BS ${data.firstYear}–${bsLastYear()}).`,
124
+ );
125
+ }
126
+
127
+ // Last year whose start is on or before the day.
128
+ let low = 0;
129
+ let high = yearStart.length - 2;
130
+ while (low < high) {
131
+ const middle = Math.ceil((low + high) / 2);
132
+ if ((yearStart[middle] ?? 0) <= epoch) {
133
+ low = middle;
134
+ } else {
135
+ high = middle - 1;
136
+ }
137
+ }
138
+ const dayOfYear = epoch - (yearStart[low] ?? 0);
139
+ let month = 11;
140
+ while (month > 0 && (monthOffset[low * 12 + month] ?? 0) > dayOfYear) {
141
+ month--;
142
+ }
143
+
144
+ return {
145
+ year: data.firstYear + low,
146
+ month: month + 1,
147
+ day: dayOfYear - (monthOffset[low * 12 + month] ?? 0) + 1,
148
+ };
149
+ }
150
+
151
+ /** Whether a BS year is after the last year checked against a published calendar. */
152
+ export function isProjectedBsYear(year: number): boolean {
153
+ return year > data.verifiedThrough;
154
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { getConfig } from './config';
2
+ import { fromIsoTimestamp, fromJsDate, toCalendar } from './convert';
3
+ import type { AdDate, CalendarSystem, DateOf } from './types';
4
+
5
+ /**
6
+ * Today's date by the calendar day in `timeZone` (Nepal by default), not the
7
+ * device's zone. The only function in the library that reads the clock.
8
+ */
9
+ export function today(): AdDate;
10
+ export function today<C extends CalendarSystem>(
11
+ calendar: C,
12
+ timeZone?: string,
13
+ ): DateOf<C>;
14
+ export function today(
15
+ calendar: CalendarSystem = 'AD',
16
+ timeZone: string = getConfig().timeZone,
17
+ ): AdDate | DateOf<CalendarSystem> {
18
+ return toCalendar(fromJsDate(new Date(), timeZone), calendar);
19
+ }
20
+
21
+ /** The current calendar day and time (HH:MM) in `timeZone` (Nepal by default). */
22
+ export function now(timeZone: string = getConfig().timeZone): {
23
+ readonly date: AdDate;
24
+ readonly time: { readonly hour: number; readonly minute: number };
25
+ } {
26
+ const current = fromIsoTimestamp(new Date().toISOString(), timeZone);
27
+ const [hour = 0, minute = 0] = (current?.time ?? '00:00').split(':').map(Number);
28
+
29
+ return {
30
+ date: current?.date ?? fromJsDate(new Date(), timeZone),
31
+ time: Object.freeze({ hour, minute }),
32
+ };
33
+ }
package/src/compare.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { inCalendarOf } from './arithmetic';
2
+ import { getConfig } from './config';
3
+ import { toCalendar, toEpoch } from './convert';
4
+ import { weekdayOf } from './epoch';
5
+ import type { CalendarDate, DateOf, Weekday } from './types';
6
+
7
+ type Maybe = CalendarDate | null | undefined;
8
+
9
+ /** -1, 0 or 1. Dates in different calendars compare by the day they fall on. */
10
+ export function compareDates(a: CalendarDate, b: CalendarDate): -1 | 0 | 1 {
11
+ return Math.sign(toEpoch(a) - toEpoch(b)) as -1 | 0 | 1;
12
+ }
13
+
14
+ /** Same day, in any calendars. False when either is missing. */
15
+ export function isSameDay(a: Maybe, b: Maybe): boolean {
16
+ return a != null && b != null && toEpoch(a) === toEpoch(b);
17
+ }
18
+
19
+ /** Whether `b` falls in the same month as `a`, judged in `a`'s calendar. */
20
+ export function isSameMonth(a: CalendarDate, b: CalendarDate): boolean {
21
+ const other = inCalendarOf(a, b);
22
+
23
+ return other.year === a.year && other.month === a.month;
24
+ }
25
+
26
+ /** Whether `b` falls in the same year as `a`, judged in `a`'s calendar. */
27
+ export function isSameYear(a: CalendarDate, b: CalendarDate): boolean {
28
+ return inCalendarOf(a, b).year === a.year;
29
+ }
30
+
31
+ export function isBefore(a: CalendarDate, b: CalendarDate): boolean {
32
+ return toEpoch(a) < toEpoch(b);
33
+ }
34
+
35
+ export function isAfter(a: CalendarDate, b: CalendarDate): boolean {
36
+ return toEpoch(a) > toEpoch(b);
37
+ }
38
+
39
+ /** On or after `min` and on or before `max`; a missing bound is open. */
40
+ export function isWithin(date: CalendarDate, min?: Maybe, max?: Maybe): boolean {
41
+ const day = toEpoch(date);
42
+
43
+ return (min == null || day >= toEpoch(min)) && (max == null || day <= toEpoch(max));
44
+ }
45
+
46
+ /** The date, or the nearest bound when it is outside, in the date's calendar. */
47
+ export function clampDate<D extends CalendarDate>(
48
+ date: D,
49
+ min?: Maybe,
50
+ max?: Maybe,
51
+ ): DateOf<D['calendar']> {
52
+ if (min != null && isBefore(date, min)) {
53
+ return toCalendar(min, date.calendar) as DateOf<D['calendar']>;
54
+ }
55
+ if (max != null && isAfter(date, max)) {
56
+ return toCalendar(max, date.calendar) as DateOf<D['calendar']>;
57
+ }
58
+
59
+ return date as DateOf<D['calendar']>;
60
+ }
61
+
62
+ /** 0 = Sunday … 6 = Saturday. The same in both calendars. */
63
+ export function dayOfWeek(date: CalendarDate): Weekday {
64
+ return weekdayOf(toEpoch(date));
65
+ }
66
+
67
+ /** Saturday by default (Nepal); set `weekendDays` in configure() or pass them here. */
68
+ export function isWeekend(
69
+ date: CalendarDate,
70
+ weekendDays: readonly Weekday[] = getConfig().weekendDays,
71
+ ): boolean {
72
+ return weekendDays.includes(dayOfWeek(date));
73
+ }
package/src/config.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { NepDateRangeError } from './errors';
2
+ import { hasLocale } from './locale/registry';
3
+ import type { LocaleCode, Weekday } from './types';
4
+
5
+ /**
6
+ * Settings shared by both calendars. Calendar data is not here: the BS table is
7
+ * managed by setBsData/patchBsYear, and AD needs no data (docs/plan D-001).
8
+ */
9
+ export interface NepDateConfig {
10
+ /** Which time zone decides "today" and the calendar day of an instant. */
11
+ readonly timeZone: string;
12
+ readonly weekStartsOn: Weekday;
13
+ readonly weekendDays: readonly Weekday[];
14
+ readonly defaultLocale: LocaleCode;
15
+ }
16
+
17
+ const DEFAULTS: NepDateConfig = Object.freeze({
18
+ timeZone: 'Asia/Kathmandu',
19
+ weekStartsOn: 0,
20
+ weekendDays: Object.freeze<Weekday[]>([6]),
21
+ defaultLocale: 'en',
22
+ });
23
+
24
+ let current: NepDateConfig = DEFAULTS;
25
+
26
+ /** Change one or more settings. Every value is checked before any is applied. */
27
+ export function configure(changes: Partial<NepDateConfig>): void {
28
+ const next = { ...current, ...changes };
29
+
30
+ if (!isTimeZone(next.timeZone)) {
31
+ throw new NepDateRangeError(
32
+ 'INVALID_ARGUMENT',
33
+ `"${next.timeZone}" is not a time zone.`,
34
+ );
35
+ }
36
+ if (!isWeekday(next.weekStartsOn)) {
37
+ throw new NepDateRangeError(
38
+ 'INVALID_ARGUMENT',
39
+ `weekStartsOn must be 0–6, not ${String(next.weekStartsOn)}.`,
40
+ );
41
+ }
42
+ if (
43
+ !next.weekendDays.every(isWeekday) ||
44
+ new Set(next.weekendDays).size !== next.weekendDays.length
45
+ ) {
46
+ throw new NepDateRangeError(
47
+ 'INVALID_ARGUMENT',
48
+ 'weekendDays must be different weekdays, each 0–6.',
49
+ );
50
+ }
51
+ if (!hasLocale(next.defaultLocale)) {
52
+ throw new NepDateRangeError(
53
+ 'INVALID_ARGUMENT',
54
+ `Locale "${next.defaultLocale}" is not registered.`,
55
+ );
56
+ }
57
+
58
+ current = Object.freeze({
59
+ ...next,
60
+ weekendDays: Object.freeze([...next.weekendDays]),
61
+ });
62
+ }
63
+
64
+ export function getConfig(): NepDateConfig {
65
+ return current;
66
+ }
67
+
68
+ /** Back to the defaults (Kathmandu, Sunday first, Saturday weekend, English). */
69
+ export function resetConfig(): void {
70
+ current = DEFAULTS;
71
+ }
72
+
73
+ export function isWeekday(value: unknown): value is Weekday {
74
+ return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 6;
75
+ }
76
+
77
+ function isTimeZone(value: string): boolean {
78
+ try {
79
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
80
+
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The only place the AD and BS calendars meet, and only through EpochDay
3
+ * (docs/plan D-001). Nothing here looks up a year number across calendars.
4
+ */
5
+ import { adToEpoch, epochToAd } from './ad/calendar';
6
+ import { bsEpochRange, bsToEpoch, epochToBs } from './bs/table';
7
+ import { getConfig } from './config';
8
+ import { adDate, bsDate } from './create';
9
+ import { toEpochDay } from './epoch';
10
+ import { NepDateRangeError } from './errors';
11
+ import type {
12
+ AdDate,
13
+ BsDate,
14
+ CalendarDate,
15
+ CalendarSystem,
16
+ DateOf,
17
+ EpochDay,
18
+ } from './types';
19
+
20
+ /** The day count of a date in either calendar. */
21
+ export function toEpoch(date: CalendarDate): EpochDay {
22
+ return toEpochDay(
23
+ date.calendar === 'AD'
24
+ ? adToEpoch(date.year, date.month, date.day)
25
+ : bsToEpoch(date.year, date.month, date.day),
26
+ );
27
+ }
28
+
29
+ /** The date of a day count in a calendar. BS throws OUT_OF_TABLE outside the table. */
30
+ export function fromEpoch<C extends CalendarSystem>(
31
+ epoch: EpochDay,
32
+ calendar: C,
33
+ ): DateOf<C> {
34
+ if (calendar === 'AD') {
35
+ const { year, month, day } = epochToAd(epoch);
36
+
37
+ return adDate(year, month, day) as DateOf<C>;
38
+ }
39
+ assertConvertible(epoch);
40
+ const { year, month, day } = epochToBs(epoch);
41
+
42
+ return bsDate(year, month, day) as DateOf<C>;
43
+ }
44
+
45
+ export function adToBs(date: AdDate): BsDate {
46
+ return fromEpoch(toEpoch(date), 'BS');
47
+ }
48
+
49
+ export function bsToAd(date: BsDate): AdDate {
50
+ return fromEpoch(toEpoch(date), 'AD');
51
+ }
52
+
53
+ /** The same day in the given calendar (the date itself when it is already in it). */
54
+ export function toCalendar<C extends CalendarSystem>(
55
+ date: CalendarDate,
56
+ calendar: C,
57
+ ): DateOf<C> {
58
+ if (date.calendar === calendar) {
59
+ return date as DateOf<C>;
60
+ }
61
+
62
+ return fromEpoch(toEpoch(date), calendar);
63
+ }
64
+
65
+ /** The first day that can be converted, in either calendar (the start of the BS table). */
66
+ export function minDate<C extends CalendarSystem>(calendar: C): DateOf<C> {
67
+ return fromEpoch(toEpochDay(bsEpochRange().first), calendar);
68
+ }
69
+
70
+ /** The last day that can be converted, in either calendar (the end of the BS table). */
71
+ export function maxDate<C extends CalendarSystem>(calendar: C): DateOf<C> {
72
+ return fromEpoch(toEpochDay(bsEpochRange().last), calendar);
73
+ }
74
+
75
+ /** Whether a date can be converted to the other calendar. */
76
+ export function isConvertible(date: CalendarDate): boolean {
77
+ const epoch = toEpoch(date);
78
+ const { first, last } = bsEpochRange();
79
+
80
+ return epoch >= first && epoch <= last;
81
+ }
82
+
83
+ /** The AD day as "YYYY-MM-DD", the form to store and send (docs/plan D-003). */
84
+ export function toIsoDate(date: CalendarDate): string {
85
+ const { year, month, day } = toCalendar(date, 'AD');
86
+ if (year < 0 || year > 9999) {
87
+ throw new NepDateRangeError(
88
+ 'INVALID_ARGUMENT',
89
+ `AD year ${year} cannot be written as YYYY-MM-DD.`,
90
+ );
91
+ }
92
+
93
+ return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
94
+ }
95
+
96
+ /**
97
+ * An AD date from "YYYY-MM-DD", or from a longer ISO timestamp by its date part
98
+ * ("2026-10-18T09:30:00Z" → 2026-10-18). Null for anything else.
99
+ */
100
+ export function fromIsoDate(value: string | null | undefined): AdDate | null {
101
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/.exec(value ?? '');
102
+ if (!match) {
103
+ return null;
104
+ }
105
+ try {
106
+ return adDate(Number(match[1]), Number(match[2]), Number(match[3]));
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /** The AD calendar day an instant falls on in a time zone (Nepal by default). */
113
+ export function fromJsDate(
114
+ instant: Date,
115
+ timeZone: string = getConfig().timeZone,
116
+ ): AdDate {
117
+ if (Number.isNaN(instant.getTime())) {
118
+ throw new NepDateRangeError('INVALID_ARGUMENT', 'The Date is invalid.');
119
+ }
120
+ let parts: Intl.DateTimeFormatPart[];
121
+ try {
122
+ parts = new Intl.DateTimeFormat('en-US', {
123
+ timeZone,
124
+ year: 'numeric',
125
+ month: 'numeric',
126
+ day: 'numeric',
127
+ era: 'short',
128
+ }).formatToParts(instant);
129
+ } catch {
130
+ throw new NepDateRangeError('INVALID_ARGUMENT', `"${timeZone}" is not a time zone.`);
131
+ }
132
+ const part = (type: Intl.DateTimeFormatPartTypes): string =>
133
+ parts.find((item) => item.type === type)?.value ?? '';
134
+ const year = Number(part('year'));
135
+
136
+ return adDate(
137
+ part('era') === 'BC' ? 1 - year : year,
138
+ Number(part('month')),
139
+ Number(part('day')),
140
+ );
141
+ }
142
+
143
+ /**
144
+ * The calendar day and time of an ISO timestamp *with a zone* ("…Z",
145
+ * "…+05:45"), read in `timeZone` (Nepal by default). Null for anything else,
146
+ * including plain dates, which have no instant.
147
+ */
148
+ export function fromIsoTimestamp(
149
+ value: string | null | undefined,
150
+ timeZone: string = getConfig().timeZone,
151
+ ): { readonly date: AdDate; readonly time: string } | null {
152
+ if (!value || !/T.*(?:Z|[+-]\d{2}:?\d{2})$/.test(value)) {
153
+ return null;
154
+ }
155
+ const instant = new Date(value);
156
+ if (Number.isNaN(instant.getTime())) {
157
+ return null;
158
+ }
159
+ const time = new Intl.DateTimeFormat('en-GB', {
160
+ timeZone,
161
+ hour: '2-digit',
162
+ minute: '2-digit',
163
+ hourCycle: 'h23',
164
+ }).format(instant);
165
+
166
+ return { date: fromJsDate(instant, timeZone), time };
167
+ }
168
+
169
+ function assertConvertible(epoch: number): void {
170
+ const { first, last } = bsEpochRange();
171
+ if (epoch < first || epoch > last) {
172
+ const { year, month, day } = epochToAd(epoch);
173
+ const from = epochToAd(first);
174
+ const to = epochToAd(last);
175
+ throw new NepDateRangeError(
176
+ 'OUT_OF_TABLE',
177
+ `AD ${isoParts(year, month, day)} is outside the BS table (AD ${isoParts(from.year, from.month, from.day)} to ${isoParts(to.year, to.month, to.day)}).`,
178
+ );
179
+ }
180
+ }
181
+
182
+ function isoParts(year: number, month: number, day: number): string {
183
+ return `${year}-${pad(month, 2)}-${pad(day, 2)}`;
184
+ }
185
+
186
+ function pad(value: number, length: number): string {
187
+ return String(value).padStart(length, '0');
188
+ }
package/src/create.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { adMonthLength, isValidAdDate, MAX_AD_YEAR } from './ad/calendar';
2
+ import { bsMonthLength, isValidBsDate } from './bs/table';
3
+ import { NepDateRangeError } from './errors';
4
+ import type {
5
+ AdDate,
6
+ BsDate,
7
+ CalendarDate,
8
+ CalendarSystem,
9
+ DateOf,
10
+ MonthNumber,
11
+ } from './types';
12
+
13
+ /** An AD date. Any year within ±999,999 is allowed; only conversion to BS is limited. */
14
+ export function adDate(year: number, month: number, day: number): AdDate {
15
+ if (!isValidAdDate(year, month, day)) {
16
+ throw new NepDateRangeError(
17
+ Number.isInteger(year) && Number.isInteger(month) && Number.isInteger(day)
18
+ ? 'INVALID_DATE'
19
+ : 'INVALID_ARGUMENT',
20
+ Math.abs(year) > MAX_AD_YEAR
21
+ ? `AD year ${String(year)} is outside ±${MAX_AD_YEAR}.`
22
+ : `AD ${String(year)}-${String(month)}-${String(day)} is not a valid date.`,
23
+ );
24
+ }
25
+
26
+ return Object.freeze({ calendar: 'AD', year, month: month as MonthNumber, day });
27
+ }
28
+
29
+ /** A BS date. The year must be in the BS table. */
30
+ export function bsDate(year: number, month: number, day: number): BsDate {
31
+ const length = bsMonthLength(year, month);
32
+ if (!Number.isInteger(day) || day < 1 || day > length) {
33
+ throw new NepDateRangeError(
34
+ Number.isInteger(day) ? 'INVALID_DATE' : 'INVALID_ARGUMENT',
35
+ `BS ${year}-${month} has ${length} days, not ${String(day)}.`,
36
+ );
37
+ }
38
+
39
+ return Object.freeze({ calendar: 'BS', year, month: month as MonthNumber, day });
40
+ }
41
+
42
+ export function createDate<C extends CalendarSystem>(
43
+ calendar: C,
44
+ year: number,
45
+ month: number,
46
+ day: number,
47
+ ): DateOf<C> {
48
+ return (
49
+ calendar === 'AD' ? adDate(year, month, day) : bsDate(year, month, day)
50
+ ) as DateOf<C>;
51
+ }
52
+
53
+ export function isValidDate(
54
+ calendar: CalendarSystem,
55
+ year: number,
56
+ month: number,
57
+ day: number,
58
+ ): boolean {
59
+ return calendar === 'AD'
60
+ ? isValidAdDate(year, month, day)
61
+ : isValidBsDate(year, month, day);
62
+ }
63
+
64
+ /** Days in a month of either calendar. */
65
+ export function daysInMonth(
66
+ calendar: CalendarSystem,
67
+ year: number,
68
+ month: number,
69
+ ): number {
70
+ if (calendar === 'BS') {
71
+ return bsMonthLength(year, month);
72
+ }
73
+ if (!isValidAdDate(year, month, 1)) {
74
+ throw new NepDateRangeError(
75
+ 'INVALID_DATE',
76
+ `AD ${String(year)}-${String(month)} is not a month.`,
77
+ );
78
+ }
79
+
80
+ return adMonthLength(year, month);
81
+ }
82
+
83
+ /** True for a valid, frozen-or-not AD date object (e.g. from JSON). */
84
+ export function isAdDate(value: unknown): value is AdDate {
85
+ return isDateLike(value, 'AD') && isValidAdDate(value.year, value.month, value.day);
86
+ }
87
+
88
+ /** True for a valid BS date object (e.g. from JSON). */
89
+ export function isBsDate(value: unknown): value is BsDate {
90
+ return isDateLike(value, 'BS') && isValidBsDate(value.year, value.month, value.day);
91
+ }
92
+
93
+ export function isCalendarDate(value: unknown): value is CalendarDate {
94
+ return isAdDate(value) || isBsDate(value);
95
+ }
96
+
97
+ function isDateLike(
98
+ value: unknown,
99
+ calendar: CalendarSystem,
100
+ ): value is { calendar: CalendarSystem; year: number; month: number; day: number } {
101
+ if (typeof value !== 'object' || value === null) {
102
+ return false;
103
+ }
104
+ const record = value as Record<string, unknown>;
105
+
106
+ return (
107
+ record['calendar'] === calendar &&
108
+ typeof record['year'] === 'number' &&
109
+ typeof record['month'] === 'number' &&
110
+ typeof record['day'] === 'number'
111
+ );
112
+ }
package/src/data.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** The raw BS table bundled with @nepdatejs/core, for tools that need the data itself. */
2
+ export { BS_DATA, type GeneratedBsData } from './bs/data/years';
package/src/epoch.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { NepDateRangeError } from './errors';
2
+ import type { EpochDay, Weekday } from './types';
3
+
4
+ /** Brand a whole number of days since 1970-01-01 as an `EpochDay`. */
5
+ export function toEpochDay(value: number): EpochDay {
6
+ if (!Number.isSafeInteger(value)) {
7
+ throw new NepDateRangeError(
8
+ 'INVALID_ARGUMENT',
9
+ `${String(value)} is not a whole number of days.`,
10
+ );
11
+ }
12
+
13
+ return value as EpochDay;
14
+ }
15
+
16
+ /** The weekday of a day count. 1970-01-01 was a Thursday. */
17
+ export function weekdayOf(epoch: EpochDay): Weekday {
18
+ return ((((epoch + 4) % 7) + 7) % 7) as Weekday;
19
+ }