faerun-date
Version:
Canonical Harptos calendar utilities for JavaScript
553 lines (440 loc) • 15 kB
JavaScript
import {
DAYS_PER_MONTH,
DAYS_PER_TENDAY,
FESTIVAL_BY_NAME,
FESTIVAL_SPECS,
MONTH_INDEX,
MONTH_SEASONS,
MONTHS,
ORDINAL_SUFFIXES,
TENDAYS_PER_MONTH
} from "./harptos-constants.js";
function isValidDate(value) {
return value instanceof Date && !Number.isNaN(value.getTime());
}
function getOrdinal(value) {
const mod100 = value % 100;
if (mod100 >= 11 && mod100 <= 13) {
return `${value}th`;
}
return `${value}${ORDINAL_SUFFIXES[value % 10] ?? "th"}`;
}
function normalizeMonthName(month) {
if (typeof month !== "string") {
return null;
}
return MONTHS.find(name => name.toLowerCase() === month.toLowerCase()) ?? null;
}
function normalizeFestivalName(festival) {
if (typeof festival !== "string") {
return null;
}
return FESTIVAL_SPECS.find(spec => spec.name.toLowerCase() === festival.toLowerCase())?.name ?? null;
}
function parseGregorianString(value) {
const isoDateOnlyMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (isoDateOnlyMatch) {
const [, year, month, day] = isoDateOnlyMatch;
const numericYear = Number(year);
const numericMonth = Number(month);
const numericDay = Number(day);
const parsed = new Date(numericYear, numericMonth - 1, numericDay);
if (
parsed.getFullYear() !== numericYear ||
parsed.getMonth() !== numericMonth - 1 ||
parsed.getDate() !== numericDay
) {
return null;
}
return parsed;
}
const parsed = new Date(value);
return isValidDate(parsed) ? parsed : null;
}
function isHarptosLeapYear(year) {
return year % 4 === 0;
}
function isGregorianLeapYear(year) {
return year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
}
function getGregorianDayOfYear(date) {
const year = date.getFullYear();
const startOfYear = Date.UTC(year, 0, 1);
const currentDay = Date.UTC(year, date.getMonth(), date.getDate());
const millisecondsPerDay = 24 * 60 * 60 * 1000;
const dayOfYear = Math.floor((currentDay - startOfYear) / millisecondsPerDay) + 1;
const maxDayOfYear = isGregorianLeapYear(year) ? 366 : 365;
if (dayOfYear < 1 || dayOfYear > maxDayOfYear) {
throw new RangeError(`Computed Gregorian day of year out of range: ${dayOfYear}`);
}
return dayOfYear;
}
function getHarptosYearLength(year) {
return isHarptosLeapYear(year) ? 366 : 365;
}
function getHarptosEntries(leapYear) {
const entries = [];
for (const month of MONTHS) {
for (let day = 1; day <= DAYS_PER_MONTH; day += 1) {
entries.push({
kind: "month-day",
month,
day,
festival: null,
season: MONTH_SEASONS[month]
});
}
for (const festival of FESTIVAL_SPECS) {
if (festival.afterMonth !== month) {
continue;
}
if (festival.leapYearOnly && !leapYear) {
continue;
}
entries.push({
kind: "festival",
month: null,
day: null,
festival: festival.name,
season: festival.season
});
}
}
return entries.map((entry, index) => {
if (entry.kind === "festival") {
return {
...entry,
dayOfYear: index + 1,
tenday: null,
dayOfTenday: null
};
}
const monthIndex = MONTH_INDEX[entry.month];
return {
...entry,
dayOfYear: index + 1,
tenday: monthIndex * TENDAYS_PER_MONTH + Math.floor((entry.day - 1) / DAYS_PER_TENDAY) + 1,
dayOfTenday: ((entry.day - 1) % DAYS_PER_TENDAY) + 1
};
});
}
function getHarptosYear(options = {}, fallbackYear = null) {
if (typeof options.drYear === "number") {
return options.drYear;
}
if (typeof options.faerunYear === "number") {
return options.faerunYear;
}
if (typeof options.yearOffset === "number" && typeof fallbackYear === "number") {
return fallbackYear + options.yearOffset;
}
return typeof fallbackYear === "number" ? fallbackYear : null;
}
function createStateFromEntry(entry, harptosYear, source) {
const leapYear = typeof harptosYear === "number" && isHarptosLeapYear(harptosYear);
return {
...entry,
harptosYear,
source,
leapYear
};
}
function createStateFromDayOfYear(dayOfYear, harptosYear, source) {
const leapYear = typeof harptosYear === "number" && isHarptosLeapYear(harptosYear);
const entries = getHarptosEntries(leapYear);
const entry = entries[dayOfYear - 1];
if (!entry) {
throw new RangeError(`Invalid dayOfYear: ${dayOfYear}.`);
}
return createStateFromEntry(entry, harptosYear, source);
}
function createStateFromGregorian(input, options = {}) {
const date = input instanceof Date ? new Date(input.getTime()) : parseGregorianString(input);
if (!isValidDate(date)) {
throw new TypeError("Expected a valid Gregorian Date or date string.");
}
const harptosYear = getHarptosYear(options, date.getFullYear());
const leapYear = isHarptosLeapYear(harptosYear ?? date.getFullYear());
const entries = getHarptosEntries(leapYear);
const dayOfYear = getGregorianDayOfYear(date);
const entry = entries[dayOfYear - 1];
if (!entry) {
throw new RangeError(
`Gregorian day ${dayOfYear} does not exist in Harptos year ${harptosYear}.`
);
}
return createStateFromEntry(entry, harptosYear, {
type: "gregorian",
date
});
}
function createStateFromHarptos(input, options = {}) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("Expected a Harptos date object.");
}
const harptosYear = getHarptosYear(options, input.year ?? null);
if (input.dayOfYear != null) {
return createStateFromDayOfYear(input.dayOfYear, harptosYear, {
type: "harptos",
input
});
}
const festivalName = normalizeFestivalName(input.festival);
if (festivalName) {
const leapYear = typeof harptosYear === "number" && isHarptosLeapYear(harptosYear);
const entries = getHarptosEntries(leapYear);
const entry = entries.find(candidate => candidate.festival === festivalName);
if (!entry) {
throw new RangeError(`${festivalName} does not occur in ${harptosYear}.`);
}
return createStateFromEntry(entry, harptosYear, {
type: "harptos",
input
});
}
const monthName = normalizeMonthName(input.month);
const day = Number(input.day);
if (!monthName || !Number.isInteger(day) || day < 1 || day > DAYS_PER_MONTH) {
throw new TypeError("Expected { month, day } for a Harptos month-day, or { festival }.");
}
const leapYear = typeof harptosYear === "number" && isHarptosLeapYear(harptosYear);
const entries = getHarptosEntries(leapYear);
const entry = entries.find(candidate => candidate.month === monthName && candidate.day === day);
return createStateFromEntry(entry, harptosYear, {
type: "harptos",
input
});
}
function createState(input, options = {}) {
if (isValidDate(input) || typeof input === "string") {
return createStateFromGregorian(input, options);
}
return createStateFromHarptos(input, options);
}
function normalizeMonthShift(monthIndex, amount) {
const shifted = monthIndex + amount;
const normalizedMonthIndex = ((shifted % MONTHS.length) + MONTHS.length) % MONTHS.length;
const yearOffset = (shifted - normalizedMonthIndex) / MONTHS.length;
return {
monthIndex: normalizedMonthIndex,
yearOffset
};
}
function shiftDayOfYear(harptosYear, dayOfYear, amount) {
if (typeof harptosYear !== "number") {
throw new TypeError("This operation requires a Harptos year.");
}
let targetYear = harptosYear;
let targetDay = dayOfYear + amount;
while (targetDay < 1) {
targetYear -= 1;
targetDay += getHarptosYearLength(targetYear);
}
while (targetDay > getHarptosYearLength(targetYear)) {
targetDay -= getHarptosYearLength(targetYear);
targetYear += 1;
}
return { year: targetYear, dayOfYear: targetDay };
}
function coerceHarptosDate(value) {
return value instanceof HarptosDate ? value : new HarptosDate(value);
}
class HarptosDate {
static MONTHS = MONTHS;
static FESTIVALS = FESTIVAL_SPECS.map(({ name }) => name);
static DAYS_PER_MONTH = DAYS_PER_MONTH;
static DAYS_PER_TENDAY = DAYS_PER_TENDAY;
constructor(input, options = {}) {
const state = createState(input, options);
Object.assign(this, state);
}
static isLeapYear(year) {
return isHarptosLeapYear(year);
}
static fromGregorian(input, options = {}) {
return new HarptosDate(input, options);
}
static fromHarptos(input, options = {}) {
return new HarptosDate(input, options);
}
static fromFaerunParts(input, options = {}) {
return HarptosDate.fromHarptos(input, options);
}
static parse(input, options = {}) {
return HarptosDate.fromGregorian(input, options);
}
static compare(left, right) {
const first = coerceHarptosDate(left);
const second = coerceHarptosDate(right);
if (typeof first.harptosYear === "number" && typeof second.harptosYear === "number") {
if (first.harptosYear !== second.harptosYear) {
return first.harptosYear - second.harptosYear;
}
return first.dayOfYear - second.dayOfYear;
}
if (first.harptosYear == null && second.harptosYear == null) {
return first.dayOfYear - second.dayOfYear;
}
throw new RangeError("Cannot compare Harptos dates when only one side has a year.");
}
static toString(value) {
return value.toString();
}
isFestival() {
return this.kind === "festival";
}
getFestival() {
return this.festival;
}
getMonth() {
return this.month;
}
getMonthIndex() {
return this.month == null ? null : MONTH_INDEX[this.month];
}
getDate() {
return this.day;
}
getDay() {
return this.day;
}
getDayOfYear() {
return this.dayOfYear;
}
getSeason() {
return this.season;
}
getFaerunYear() {
return this.harptosYear;
}
getHarptosYear() {
return this.harptosYear;
}
getTenday() {
return this.tenday;
}
getWeekOfYear() {
return this.getTenday();
}
getDayOfTenday() {
return this.dayOfTenday;
}
getWeekday() {
if (this.dayOfTenday == null) {
return null;
}
return `${getOrdinal(this.dayOfTenday)} day of the tenday`;
}
addDays(amount) {
if (!Number.isInteger(amount)) {
throw new TypeError("addDays expects an integer number of days.");
}
const shifted = shiftDayOfYear(this.harptosYear, this.dayOfYear, amount);
return HarptosDate.fromHarptos(shifted);
}
addTendays(amount) {
if (!Number.isInteger(amount)) {
throw new TypeError("addTendays expects an integer number of tendays.");
}
return this.addDays(amount * DAYS_PER_TENDAY);
}
addMonths(amount) {
if (!Number.isInteger(amount)) {
throw new TypeError("addMonths expects an integer number of months.");
}
if (typeof this.harptosYear !== "number") {
throw new TypeError("addMonths requires a Harptos year.");
}
if (this.isFestival()) {
throw new TypeError("addMonths is only supported for month dates.");
}
const shifted = normalizeMonthShift(this.getMonthIndex(), amount);
return HarptosDate.fromHarptos({
year: this.harptosYear + shifted.yearOffset,
month: MONTHS[shifted.monthIndex],
day: this.day
});
}
addYears(amount) {
if (!Number.isInteger(amount)) {
throw new TypeError("addYears expects an integer number of years.");
}
if (typeof this.harptosYear !== "number") {
throw new TypeError("addYears requires a Harptos year.");
}
const targetYear = this.harptosYear + amount;
if (this.isFestival()) {
if (this.festival === "Shieldmeet" && !isHarptosLeapYear(targetYear)) {
throw new RangeError(`Shieldmeet does not occur in ${targetYear}.`);
}
return HarptosDate.fromHarptos({
year: targetYear,
festival: this.festival
});
}
return HarptosDate.fromHarptos({
year: targetYear,
month: this.month,
day: this.day
});
}
toFaerunParts() {
return {
kind: this.kind,
year: this.harptosYear,
harptosYear: this.harptosYear,
month: this.month,
monthIndex: this.getMonthIndex(),
day: this.day,
date: this.day,
festival: this.festival,
season: this.season,
dayOfYear: this.dayOfYear,
tenday: this.tenday,
dayOfTenday: this.dayOfTenday,
leapYear: this.leapYear
};
}
toObject() {
return this.toFaerunParts();
}
getFaerunDateString() {
return this.toString();
}
toString() {
const yearPart = this.harptosYear == null ? "" : ` ${this.harptosYear} DR`;
if (this.isFestival()) {
return `${this.festival}${yearPart}`;
}
return `${this.day} ${this.month}${yearPart}`;
}
toLocaleString() {
if (this.isFestival()) {
return `${this.toString()} - ${this.season} festival`;
}
return `${this.toString()} - ${this.season} - Tenday ${this.tenday}, Day ${this.dayOfTenday}`;
}
}
const FaerunDate = HarptosDate;
function fromGregorian(input, options = {}) {
return HarptosDate.fromGregorian(input, options);
}
function fromHarptos(input, options = {}) {
return HarptosDate.fromHarptos(input, options);
}
function fromFaerunParts(input, options = {}) {
return HarptosDate.fromFaerunParts(input, options);
}
export {
DAYS_PER_MONTH,
DAYS_PER_TENDAY,
FESTIVAL_SPECS as FESTIVALS,
FaerunDate,
HarptosDate,
MONTHS,
TENDAYS_PER_MONTH,
fromFaerunParts,
fromGregorian,
fromHarptos
};
export default HarptosDate;