@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.
- package/LICENSE +21 -0
- package/README.md +134 -1
- package/dist/data.cjs +3 -0
- package/dist/data.d.cts +16 -0
- package/dist/data.d.ts +16 -0
- package/dist/data.js +1 -0
- package/dist/index.cjs +1328 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +433 -0
- package/dist/index.d.ts +433 -0
- package/dist/index.js +1241 -0
- package/dist/index.js.map +1 -0
- package/dist/nepdate.global.js +2 -0
- package/dist/nepdate.global.js.map +1 -0
- package/dist/years-CabTFbN9.js +1832 -0
- package/dist/years-CabTFbN9.js.map +1 -0
- package/dist/years-J6lZENDt.cjs +1837 -0
- package/dist/years-J6lZENDt.cjs.map +1 -0
- package/package.json +77 -4
- package/src/ad/calendar.ts +71 -0
- package/src/arithmetic.ts +123 -0
- package/src/bs/data/years.ts +152 -0
- package/src/bs/data-api.ts +112 -0
- package/src/bs/table.ts +154 -0
- package/src/clock.ts +33 -0
- package/src/compare.ts +73 -0
- package/src/config.ts +85 -0
- package/src/convert.ts +188 -0
- package/src/create.ts +112 -0
- package/src/data.ts +2 -0
- package/src/epoch.ts +19 -0
- package/src/errors.ts +43 -0
- package/src/fiscal.ts +50 -0
- package/src/format.ts +88 -0
- package/src/index.ts +126 -0
- package/src/locale/en.ts +93 -0
- package/src/locale/index.ts +49 -0
- package/src/locale/ne.ts +93 -0
- package/src/locale/registry.ts +114 -0
- package/src/parse.ts +67 -0
- package/src/projected.ts +8 -0
- package/src/time.ts +186 -0
- package/src/types.ts +61 -0
package/src/parse.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createDate } from './create';
|
|
2
|
+
import { NepDateRangeError } from './errors';
|
|
3
|
+
import { fiscalYearRange } from './fiscal';
|
|
4
|
+
import { toAsciiDigits } from './locale/index';
|
|
5
|
+
import type { CalendarSystem, DateOf, FiscalYear } from './types';
|
|
6
|
+
|
|
7
|
+
const YEAR_FIRST = /^(\d{4})[-./](\d{1,2})[-./](\d{1,2})$/;
|
|
8
|
+
const COMPACT = /^(\d{4})(\d{2})(\d{2})$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A date written year first ("2083-06-24", "2083/6/24", "2083.06.24", or
|
|
12
|
+
* compact "20830624") in ASCII or Devanagari digits, in the calendar the caller names. Other orders are
|
|
13
|
+
* rejected, not guessed. Returns null when the text is not a valid date.
|
|
14
|
+
*/
|
|
15
|
+
export function parseDate<C extends CalendarSystem>(
|
|
16
|
+
text: string | null | undefined,
|
|
17
|
+
calendar: C,
|
|
18
|
+
): DateOf<C> | null {
|
|
19
|
+
try {
|
|
20
|
+
return parseDateStrict(text ?? '', calendar);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Like parseDate, but throws a NepDateRangeError that says what is wrong. */
|
|
27
|
+
export function parseDateStrict<C extends CalendarSystem>(
|
|
28
|
+
text: string,
|
|
29
|
+
calendar: C,
|
|
30
|
+
): DateOf<C> {
|
|
31
|
+
const ascii = toAsciiDigits(text).trim();
|
|
32
|
+
const match = YEAR_FIRST.exec(ascii) ?? COMPACT.exec(ascii);
|
|
33
|
+
if (!match) {
|
|
34
|
+
throw new NepDateRangeError(
|
|
35
|
+
'INVALID_ARGUMENT',
|
|
36
|
+
`"${text}" is not a date written as YYYY-MM-DD.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return createDate(calendar, Number(match[1]), Number(match[2]), Number(match[3]));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A fiscal year from its label: "2083/84", "2083/2084", "2083-84" or the
|
|
45
|
+
* tax form "2083.084", in ASCII or Devanagari digits. Null when not valid.
|
|
46
|
+
*/
|
|
47
|
+
export function parseFiscalYear(text: string | null | undefined): FiscalYear | null {
|
|
48
|
+
const match = /^(\d{4})\s*[-/.]\s*(\d{2,4})$/.exec(toAsciiDigits(text ?? '').trim());
|
|
49
|
+
if (!match) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const start = Number(match[1]);
|
|
53
|
+
const end = match[2] ?? '';
|
|
54
|
+
const next = start + 1;
|
|
55
|
+
const expected =
|
|
56
|
+
end.length === 4
|
|
57
|
+
? String(next)
|
|
58
|
+
: String(next % 10 ** end.length).padStart(end.length, '0');
|
|
59
|
+
if (end !== expected) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return fiscalYearRange(start);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/projected.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { isProjectedBsYear } from './bs/table';
|
|
2
|
+
import { toCalendar } from './convert';
|
|
3
|
+
import type { CalendarDate } from './types';
|
|
4
|
+
|
|
5
|
+
/** Whether the date's BS year is after the last year checked against a published calendar. */
|
|
6
|
+
export function isProjected(date: CalendarDate): boolean {
|
|
7
|
+
return isProjectedBsYear(toCalendar(date, 'BS').year);
|
|
8
|
+
}
|
package/src/time.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { getConfig } from './config';
|
|
2
|
+
import { fromIsoTimestamp, toCalendar } from './convert';
|
|
3
|
+
import { adDate } from './create';
|
|
4
|
+
import { NepDateRangeError } from './errors';
|
|
5
|
+
import { formatNumber, getLocale, toAsciiDigits } from './locale/index';
|
|
6
|
+
import type { AdDate, CalendarDate, LocaleCode } from './types';
|
|
7
|
+
|
|
8
|
+
/** A time of day, 00:00–23:59. Frozen like dates. */
|
|
9
|
+
export interface TimeOfDay {
|
|
10
|
+
readonly hour: number;
|
|
11
|
+
readonly minute: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function timeOfDay(hour: number, minute = 0): TimeOfDay {
|
|
15
|
+
if (!isValidTime(hour, minute)) {
|
|
16
|
+
throw new NepDateRangeError(
|
|
17
|
+
'INVALID_ARGUMENT',
|
|
18
|
+
`${String(hour)}:${String(minute)} is not a time of day.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return Object.freeze({ hour, minute });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isValidTime(hour: number, minute: number): boolean {
|
|
26
|
+
return (
|
|
27
|
+
Number.isInteger(hour) &&
|
|
28
|
+
Number.isInteger(minute) &&
|
|
29
|
+
hour >= 0 &&
|
|
30
|
+
hour <= 23 &&
|
|
31
|
+
minute >= 0 &&
|
|
32
|
+
minute <= 59
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** -1, 0 or 1. */
|
|
37
|
+
export function compareTimes(a: TimeOfDay, b: TimeOfDay): -1 | 0 | 1 {
|
|
38
|
+
return Math.sign(a.hour * 60 + a.minute - (b.hour * 60 + b.minute)) as -1 | 0 | 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* "14:30", "2:30 pm", "2 PM", "1430", "१४:३०", "२:३० अपराह्न" → TimeOfDay.
|
|
43
|
+
* Null when the text is not a time.
|
|
44
|
+
*/
|
|
45
|
+
export function parseTime(text: string | null | undefined): TimeOfDay | null {
|
|
46
|
+
let value = toAsciiDigits(text ?? '')
|
|
47
|
+
.trim()
|
|
48
|
+
.toLowerCase();
|
|
49
|
+
let meridiem: 'am' | 'pm' | null = null;
|
|
50
|
+
for (const code of ['en', 'ne'] as const) {
|
|
51
|
+
const { am, pm } = getLocale(code).time;
|
|
52
|
+
for (const [word, kind] of [
|
|
53
|
+
[am.toLowerCase(), 'am'],
|
|
54
|
+
[pm.toLowerCase(), 'pm'],
|
|
55
|
+
[am.toLowerCase().replace(/(.)/g, '$1.'), 'am'],
|
|
56
|
+
[pm.toLowerCase().replace(/(.)/g, '$1.'), 'pm'],
|
|
57
|
+
] as const) {
|
|
58
|
+
if (value.endsWith(word)) {
|
|
59
|
+
meridiem = kind;
|
|
60
|
+
value = value.slice(0, -word.length).trim();
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (meridiem) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const match =
|
|
69
|
+
/^(\d{1,2})(?:[:.](\d{2}))?$/.exec(value) ?? /^(\d{2})(\d{2})$/.exec(value);
|
|
70
|
+
if (!match) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
let hour = Number(match[1]);
|
|
74
|
+
const minute = Number(match[2] ?? 0);
|
|
75
|
+
if (meridiem) {
|
|
76
|
+
if (hour < 1 || hour > 12) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
hour = (hour % 12) + (meridiem === 'pm' ? 12 : 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return isValidTime(hour, minute) ? timeOfDay(hour, minute) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "14:30" (24-hour) or "2:30 PM" (hour12), in the locale's digits and words. */
|
|
86
|
+
export function formatTime(
|
|
87
|
+
time: TimeOfDay,
|
|
88
|
+
options: { readonly hour12?: boolean; readonly locale?: LocaleCode } = {},
|
|
89
|
+
): string {
|
|
90
|
+
const minute = String(time.minute).padStart(2, '0');
|
|
91
|
+
if (!options.hour12) {
|
|
92
|
+
return formatNumber(
|
|
93
|
+
`${String(time.hour).padStart(2, '0')}:${minute}`,
|
|
94
|
+
options.locale,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const { am, pm } = getLocale(options.locale).time;
|
|
98
|
+
const hour = time.hour % 12 === 0 ? 12 : time.hour % 12;
|
|
99
|
+
|
|
100
|
+
return `${formatNumber(`${hour}:${minute}`, options.locale)} ${time.hour < 12 ? am : pm}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The instant of a calendar day and time in a zone (Nepal by default), as an
|
|
105
|
+
* ISO timestamp with that zone's offset: "2026-10-18T14:30:00+05:45".
|
|
106
|
+
*/
|
|
107
|
+
export function toIsoDateTime(
|
|
108
|
+
date: CalendarDate,
|
|
109
|
+
time: TimeOfDay,
|
|
110
|
+
timeZone: string = getConfig().timeZone,
|
|
111
|
+
): string {
|
|
112
|
+
const { year, month, day } = toCalendar(date, 'AD');
|
|
113
|
+
if (year < 0 || year > 9999) {
|
|
114
|
+
throw new NepDateRangeError(
|
|
115
|
+
'INVALID_ARGUMENT',
|
|
116
|
+
`AD year ${year} cannot be written in ISO form.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const wall = Date.UTC(year, month - 1, day, time.hour, time.minute);
|
|
120
|
+
const first = offsetMinutes(wall, timeZone);
|
|
121
|
+
const offset = offsetMinutes(wall - first * 60_000, timeZone);
|
|
122
|
+
const sign = offset < 0 ? '-' : '+';
|
|
123
|
+
const abs = Math.abs(offset);
|
|
124
|
+
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
|
|
125
|
+
|
|
126
|
+
return `${pad(year, 4)}-${pad(month)}-${pad(day)}T${pad(time.hour)}:${pad(time.minute)}:00${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The day and time of an ISO timestamp in a zone (Nepal by default). A value
|
|
131
|
+
* without an offset ("2026-10-18T14:30", as from <input type="datetime-local">)
|
|
132
|
+
* is read as a wall time already in that zone. Null for anything else.
|
|
133
|
+
*/
|
|
134
|
+
export function fromIsoDateTime(
|
|
135
|
+
value: string | null | undefined,
|
|
136
|
+
timeZone: string = getConfig().timeZone,
|
|
137
|
+
): { readonly date: AdDate; readonly time: TimeOfDay } | null {
|
|
138
|
+
const zoned = fromIsoTimestamp(value, timeZone);
|
|
139
|
+
if (zoned) {
|
|
140
|
+
const time = parseTime(zoned.time);
|
|
141
|
+
return time ? { date: zoned.date, time } : null;
|
|
142
|
+
}
|
|
143
|
+
const local = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/.exec(
|
|
144
|
+
value ?? '',
|
|
145
|
+
);
|
|
146
|
+
if (!local) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return {
|
|
151
|
+
date: adDate(Number(local[1]), Number(local[2]), Number(local[3])),
|
|
152
|
+
time: timeOfDay(Number(local[4]), Number(local[5])),
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Minutes the zone is ahead of UTC at an instant. */
|
|
160
|
+
function offsetMinutes(instant: number, timeZone: string): number {
|
|
161
|
+
let parts: Intl.DateTimeFormatPart[];
|
|
162
|
+
try {
|
|
163
|
+
parts = new Intl.DateTimeFormat('en-US', {
|
|
164
|
+
timeZone,
|
|
165
|
+
hourCycle: 'h23',
|
|
166
|
+
year: 'numeric',
|
|
167
|
+
month: 'numeric',
|
|
168
|
+
day: 'numeric',
|
|
169
|
+
hour: 'numeric',
|
|
170
|
+
minute: 'numeric',
|
|
171
|
+
}).formatToParts(new Date(instant));
|
|
172
|
+
} catch {
|
|
173
|
+
throw new NepDateRangeError('INVALID_ARGUMENT', `"${timeZone}" is not a time zone.`);
|
|
174
|
+
}
|
|
175
|
+
const part = (type: Intl.DateTimeFormatPartTypes): number =>
|
|
176
|
+
Number(parts.find((item) => item.type === type)?.value ?? 0);
|
|
177
|
+
const wall = Date.UTC(
|
|
178
|
+
part('year'),
|
|
179
|
+
part('month') - 1,
|
|
180
|
+
part('day'),
|
|
181
|
+
part('hour'),
|
|
182
|
+
part('minute'),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return Math.round((wall - Math.floor(instant / 60_000) * 60_000) / 60_000);
|
|
186
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The two calendars this library knows. */
|
|
2
|
+
export type CalendarSystem = 'AD' | 'BS';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whole days since 1970-01-01 AD. The only value the AD and BS calendars share:
|
|
6
|
+
* every conversion goes BS → EpochDay → AD and back (docs/plan D-001, D-002).
|
|
7
|
+
* Branded so a year, month or day number can never be passed by mistake.
|
|
8
|
+
*/
|
|
9
|
+
export type EpochDay = number & { readonly __brand: 'EpochDay' };
|
|
10
|
+
|
|
11
|
+
/** 0 = Sunday … 6 = Saturday. */
|
|
12
|
+
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
13
|
+
|
|
14
|
+
/** 1 = Baisakh/January … 12 = Chaitra/December. */
|
|
15
|
+
export type MonthNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
|
16
|
+
|
|
17
|
+
/** A Gregorian (AD) calendar day. Create with `adDate()`; always frozen. */
|
|
18
|
+
export interface AdDate {
|
|
19
|
+
readonly calendar: 'AD';
|
|
20
|
+
readonly year: number;
|
|
21
|
+
readonly month: MonthNumber;
|
|
22
|
+
readonly day: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A Bikram Sambat (BS) calendar day. Create with `bsDate()`; always frozen. */
|
|
26
|
+
export interface BsDate {
|
|
27
|
+
readonly calendar: 'BS';
|
|
28
|
+
readonly year: number;
|
|
29
|
+
readonly month: MonthNumber;
|
|
30
|
+
readonly day: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CalendarDate = AdDate | BsDate;
|
|
34
|
+
|
|
35
|
+
/** The date type of a calendar: `DateOf<'BS'>` is `BsDate`. */
|
|
36
|
+
export type DateOf<C extends CalendarSystem> = C extends 'AD' ? AdDate : BsDate;
|
|
37
|
+
|
|
38
|
+
/** The BS month-length table. Keyed by BS year only; holds no AD dates (D-001). */
|
|
39
|
+
export interface BsData {
|
|
40
|
+
/** The BS year of the first row of `years`. */
|
|
41
|
+
readonly firstYear: number;
|
|
42
|
+
/** Days since 1970-01-01 of 1 Baisakh of `firstYear`. */
|
|
43
|
+
readonly epochStart: number;
|
|
44
|
+
/** Years after this one are projections, not yet checked against a published calendar. */
|
|
45
|
+
readonly verifiedThrough: number;
|
|
46
|
+
/** Twelve month lengths (Baisakh..Chaitra) per year, starting at `firstYear`. */
|
|
47
|
+
readonly years: readonly (readonly number[])[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A locale code: built in are `en` and `ne`; more can be added with `registerLocale`. */
|
|
51
|
+
export type LocaleCode = 'en' | 'ne' | (string & Record<never, never>);
|
|
52
|
+
|
|
53
|
+
/** Nepal's fiscal year: 1 Shrawan to the last day of Asar. */
|
|
54
|
+
export interface FiscalYear {
|
|
55
|
+
/** The BS year the fiscal year starts in: 2083 for FY 2083/84. */
|
|
56
|
+
readonly startYear: number;
|
|
57
|
+
/** "2083/84" */
|
|
58
|
+
readonly label: string;
|
|
59
|
+
readonly firstDay: BsDate;
|
|
60
|
+
readonly lastDay: BsDate;
|
|
61
|
+
}
|