diff --git a/examples/Ethiopic.test.tsx b/examples/Ethiopic.test.tsx index c9f3964d1..94641697d 100644 --- a/examples/Ethiopic.test.tsx +++ b/examples/Ethiopic.test.tsx @@ -1,16 +1,16 @@ import React from "react"; +import { grid } from "@/test/elements"; import { render } from "@/test/render"; import { Ethiopic } from "./Ethiopic.jsx"; -const today = new Date(2021, 10, 25); +const today = new Date(2024, 11, 22); beforeAll(() => jest.setSystemTime(today)); afterAll(() => jest.useRealTimers()); -beforeEach(() => { +test("should render ታህሳስ ፳፻፲፯", () => { render(); + expect(grid("ታህሳስ ፳፻፲፯")).toBeInTheDocument(); }); - -test.todo("should render the Ethiopic calendar"); diff --git a/examples/EthiopicGeez.test.tsx b/examples/EthiopicGeez.test.tsx new file mode 100644 index 000000000..655d5f991 --- /dev/null +++ b/examples/EthiopicGeez.test.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import { grid } from "@/test/elements"; +import { render } from "@/test/render"; + +import { EthiopicGeez } from "./EthiopicGeez"; + +const today = new Date(2024, 11, 22); + +beforeAll(() => jest.setSystemTime(today)); +afterAll(() => jest.useRealTimers()); + +test("should render Tahsas 2017 with latin numerals", () => { + render(); + expect(grid("Tahsas 2017")).toBeInTheDocument(); +}); + +test("should render December 2024 with latin numerals", () => { + render(); + expect(grid("December 2024")).toBeInTheDocument(); +}); diff --git a/examples/EthiopicGeez.tsx b/examples/EthiopicGeez.tsx new file mode 100644 index 000000000..ea4fd7fd8 --- /dev/null +++ b/examples/EthiopicGeez.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +import { DayPicker } from "react-day-picker/ethiopic"; + +export function EthiopicGeez() { + return ; +} diff --git a/examples/index.ts b/examples/index.ts index bde95af30..060c0045e 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -50,6 +50,8 @@ export * from "./PastDatesDisabled"; export * from "./Persian"; export * from "./PersianFormatted"; export * from "./PersianEn"; +export * from "./Ethiopic"; +export * from "./EthiopicGeez"; export * from "./Range"; export * from "./RangeExcludeDisabled"; export * from "./RangeLong"; diff --git a/src/ethiopic/index.tsx b/src/ethiopic/index.tsx index c0c637325..e84329ed1 100644 --- a/src/ethiopic/index.tsx +++ b/src/ethiopic/index.tsx @@ -49,7 +49,7 @@ export function DayPicker( ) { const dateLib = getDateLib({ locale: props.locale, - weekStartsOn: props.broadcastCalendar ? 1 : props.weekStartsOn, + weekStartsOn: 1, firstWeekContainsDate: props.firstWeekContainsDate, useAdditionalWeekYearTokens: props.useAdditionalWeekYearTokens, useAdditionalDayOfYearTokens: props.useAdditionalDayOfYearTokens, @@ -59,7 +59,7 @@ export function DayPicker( ); diff --git a/src/ethiopic/lib/addDays.ts b/src/ethiopic/lib/addDays.ts index 91974defc..0cda9dd84 100644 --- a/src/ethiopic/lib/addDays.ts +++ b/src/ethiopic/lib/addDays.ts @@ -1,3 +1,5 @@ +import { addDays as addDaysFns } from "date-fns"; + /** * Adds days to an Ethiopic date * @@ -6,7 +8,5 @@ * @returns {Date} The new date */ export function addDays(date: Date, amount: number): Date { - const julianDay = Math.floor(date.getTime() / 86400000 + 2440587.5); - const newJulianDay = julianDay + amount; - return new Date((newJulianDay - 2440587.5) * 86400000); + return addDaysFns(date, amount); } diff --git a/src/ethiopic/lib/addMonths.test.ts b/src/ethiopic/lib/addMonths.test.ts index 1b8806873..0786c8345 100644 --- a/src/ethiopic/lib/addMonths.test.ts +++ b/src/ethiopic/lib/addMonths.test.ts @@ -1 +1,81 @@ -test.todo("addMonths should correctly add months to an Ethiopic date"); +import { toEthiopicDate, toGregorianDate } from "../utils"; + +import { addMonths } from "./addMonths"; + +describe("addMonths in Ethiopian calendar", () => { + test("should add positive months correctly in Ethiopian calendar", () => { + // Test case 1: Adding within same year + const date1 = toGregorianDate({ + year: 2016, + month: 4, + day: 22 + }); // Greg: Jan 1, 2024 + const result1 = addMonths(date1, 2); + const ethResult1 = toEthiopicDate(result1); + expect(ethResult1).toEqual({ + year: 2016, + month: 6, // Yekatit(6) + day: 22 + }); // Greg: Mar 1, 2024 + + // Test case 2: Adding across gregorian year boundary + const date2 = toGregorianDate({ + year: 2016, + month: 5, + day: 22 + }); // Greg: Feb 1, 2024 + const result2 = addMonths(date2, 3); + const ethResult2 = toEthiopicDate(result2); + expect(ethResult2).toEqual({ + year: 2016, + month: 8, // Meyazia(8) + day: 22 + }); // Greg: Apr 30, 2024 + }); + + test("should add negative months correctly in Ethiopian calendar", () => { + // Test case 1: Subtracting within same year + const date1 = toGregorianDate({ + year: 2016, + month: 4, + day: 21 + }); // Greg: Dec 31, 2023 + const result1 = addMonths(date1, -2); + const ethResult1 = toEthiopicDate(result1); + expect(ethResult1).toEqual({ + year: 2016, + month: 2, // Tikimt(2) + day: 21 + }); // Greg: Oct 31, 2023 + + // Test case 2: Subtracting across gregorian year boundary + const date2 = toGregorianDate({ + year: 2016, + month: 4, + day: 21 + }); // Greg: Dec 31, 2023 + const result2 = addMonths(date2, -3); + const ethResult2 = toEthiopicDate(result2); + expect(ethResult2).toEqual({ + year: 2016, + month: 1, // Meskerem(1) + day: 21 + }); // Greg: Oct 1, 2023 + }); + + test("should handle day overflow in the 13th month Ethiopian calendar", () => { + // Test case 2: Day overflow in Pagume (13th month) + const date2 = toGregorianDate({ + year: 2016, + month: 12, + day: 25 + }); // Greg: Aug 31, 2024 + const result2 = addMonths(date2, 1); + const ethResult2 = toEthiopicDate(result2); + expect(ethResult2).toEqual({ + year: 2016, + month: 13, // Pagume + day: 5 // Adjusted from 26 to 5 (Pagume has only 5 or 6 days) + }); // Greg: Sep 5, 2024 + }); +}); diff --git a/src/ethiopic/lib/addMonths.ts b/src/ethiopic/lib/addMonths.ts index 85d2b4220..34c8d464a 100644 --- a/src/ethiopic/lib/addMonths.ts +++ b/src/ethiopic/lib/addMonths.ts @@ -1,16 +1,29 @@ +import { daysInMonth } from "../utils/daysInMonth.js"; import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; /** - * Adds months to an Ethiopic date + * Adds the specified number of months to the given Ethiopian date. Handles + * month overflow and year boundaries correctly. * - * @param {Date} date - The original date - * @param {number} amount - The number of months to add - * @returns {Date} The new date + * @param date - The starting gregorian date + * @param amount - The number of months to add (can be negative) + * @returns A new gregorian date with the months added */ export function addMonths(date: Date, amount: number): Date { const { year, month, day } = toEthiopicDate(date); - const totalMonths = month + amount - 1; - const newYear = year + Math.floor(totalMonths / 12); - const newMonth = (totalMonths % 12) + 1; - return toGregorianDate({ year: newYear, month: newMonth, day }); + let newMonth = month + amount; + const yearAdjustment = Math.floor((newMonth - 1) / 13); + newMonth = ((newMonth - 1) % 13) + 1; + + if (newMonth < 1) { + newMonth += 13; + } + + const newYear = year + yearAdjustment; + + // Adjust day if it exceeds the month length + const monthLength = daysInMonth(newMonth, newYear); + const newDay = Math.min(day, monthLength); + + return toGregorianDate({ year: newYear, month: newMonth, day: newDay }); } diff --git a/src/ethiopic/lib/addWeeks.ts b/src/ethiopic/lib/addWeeks.ts index 5363453f0..3c7b12ab6 100644 --- a/src/ethiopic/lib/addWeeks.ts +++ b/src/ethiopic/lib/addWeeks.ts @@ -7,6 +7,7 @@ import { addDays } from "./addDays.js"; * @param {number} amount - The number of weeks to add * @returns {Date} The new date */ +//TODO: We can use the addWeeks from Date-fns export function addWeeks(date: Date, amount: number): Date { return addDays(date, amount * 7); } diff --git a/src/ethiopic/lib/addYears.test.ts b/src/ethiopic/lib/addYears.test.ts index 09bfb117a..dc23f3e9a 100644 --- a/src/ethiopic/lib/addYears.test.ts +++ b/src/ethiopic/lib/addYears.test.ts @@ -1 +1,50 @@ -test.todo("addYears should correctly add years to an Ethiopic date"); +import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; + +import { addYears } from "./addYears"; + +describe("addYears in Ethiopian calendar", () => { + test("should add positive years correctly in Ethiopian calendar", () => { + const date = toGregorianDate({ + year: 2015, + month: 4, + day: 22 + }); // Greg: Jan 1, 2023 + const result = addYears(date, 2); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2017, + month: 4, // Tahsas(4) + day: 22 + }); // Greg: Jan 1, 2025 + }); + + test("should add negative years correctly in Ethiopian calendar", () => { + const date = toGregorianDate({ + year: 2016, + month: 4, + day: 21 + }); // Greg: Dec 31, 2023 + const result = addYears(date, -2); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2014, + month: 4, // Tahsas(4) + day: 21 + }); // Greg: Dec 31, 2021 + }); + + test("should maintain month and day when adding years from leap year in Ethiopian calendar", () => { + const date = toGregorianDate({ + year: 2015, + month: 13, // Pagume + day: 6 + }); // Greg: Sep 6, 2023, Leap year day + const result = addYears(date, 1); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 13, // Pagume + day: 5 + }); // Greg: Sep 6, 2024 + }); +}); diff --git a/src/ethiopic/lib/addYears.ts b/src/ethiopic/lib/addYears.ts index e80d26fd2..1eae6e303 100644 --- a/src/ethiopic/lib/addYears.ts +++ b/src/ethiopic/lib/addYears.ts @@ -1,20 +1,29 @@ import { - toEthiopicDate, isEthiopicLeapYear, + toEthiopicDate, toGregorianDate } from "../utils/index.js"; /** - * Adds years to an Ethiopic date + * Adds the specified number of years to the given Ethiopian date. Handles leap + * year transitions for Pagume month. * - * @param {Date} date - The original date - * @param {number} amount - The number of years to add - * @returns {Date} The new date + * @param date - The starting gregorian date + * @param amount - The number of years to add (can be negative) + * @returns A new gregorian date with the years added */ export function addYears(date: Date, amount: number): Date { - const { year, month, day } = toEthiopicDate(date); - const newYear = year + amount; - const newDay = - month === 13 && day === 6 && !isEthiopicLeapYear(newYear) ? 5 : day; - return toGregorianDate({ year: newYear, month, day: newDay }); + const etDate = toEthiopicDate(date); + const day = + isEthiopicLeapYear(etDate.year) && + etDate.month === 13 && + etDate.day === 6 && + amount % 4 !== 0 + ? 5 + : etDate.day; + return toGregorianDate({ + month: etDate.month, + day: day, + year: etDate.year + amount + }); } diff --git a/src/ethiopic/lib/differenceInCalendarDays.ts b/src/ethiopic/lib/differenceInCalendarDays.ts index 8e642ff67..800c92547 100644 --- a/src/ethiopic/lib/differenceInCalendarDays.ts +++ b/src/ethiopic/lib/differenceInCalendarDays.ts @@ -1,4 +1,4 @@ -import { toEthiopicDate, isEthiopicLeapYear } from "../utils/index.js"; +import { differenceInCalendarDays as differenceInCalendarDaysNative } from "date-fns"; /** * Difference in calendar days @@ -7,17 +7,11 @@ import { toEthiopicDate, isEthiopicLeapYear } from "../utils/index.js"; * @param {Date} dateRight - The earlier date * @returns {number} The number of calendar days between the two dates */ +//TODO: We can use the differenceInCalendarDays from Date-fns export function differenceInCalendarDays( dateLeft: Date, dateRight: Date ): number { - const leftYear = toEthiopicDate(dateLeft).year; - const rightYear = toEthiopicDate(dateRight).year; - const leapDays = Array.from( - { length: leftYear - rightYear }, - (_, i) => rightYear + i - ).filter(isEthiopicLeapYear).length; - return ( - Math.floor((dateLeft.getTime() - dateRight.getTime()) / 86400000) + leapDays - ); + const result = differenceInCalendarDaysNative(dateLeft, dateRight); + return result; } diff --git a/src/ethiopic/lib/differenceInCalendarMonths.test.ts b/src/ethiopic/lib/differenceInCalendarMonths.test.ts new file mode 100644 index 000000000..ae234a29e --- /dev/null +++ b/src/ethiopic/lib/differenceInCalendarMonths.test.ts @@ -0,0 +1,42 @@ +import { toGregorianDate } from "../utils"; + +import { differenceInCalendarMonths } from "./differenceInCalendarMonths"; + +describe("differenceInCalendarMonths in Ethiopian calendar", () => { + test("should calculate difference in months within the same Ethiopian year", () => { + const date1 = toGregorianDate({ + year: 2016, + month: 4, + day: 1 + }); // Greg: Dec 11, 2023 + const date2 = toGregorianDate({ + year: 2016, + month: 7, + day: 1 + }); // Greg: Mar 10, 2024 + expect(differenceInCalendarMonths(date2, date1)).toBe(3); + }); + + test("should calculate difference in months across Ethiopian years", () => { + const date1 = toGregorianDate({ + year: 2015, + month: 11, + day: 1 + }); // Greg: Jul 8, 2023 + const date2 = toGregorianDate({ + year: 2016, + month: 2, + day: 1 + }); // Greg: Oct 12, 2023 + expect(differenceInCalendarMonths(date2, date1)).toBe(4); + }); + + test("should return zero for same Ethiopian date", () => { + const date = toGregorianDate({ + year: 2016, + month: 4, + day: 15 + }); // Greg: Dec 25, 2023 + expect(differenceInCalendarMonths(date, date)).toBe(0); + }); +}); diff --git a/src/ethiopic/lib/differenceInCalendarMonths.ts b/src/ethiopic/lib/differenceInCalendarMonths.ts index 03cc06d8d..97563770c 100644 --- a/src/ethiopic/lib/differenceInCalendarMonths.ts +++ b/src/ethiopic/lib/differenceInCalendarMonths.ts @@ -1,4 +1,4 @@ -import { toEthiopicDate, isEthiopicLeapYear } from "../utils/index.js"; +import { toEthiopicDate } from "../utils/index.js"; /** * Difference in calendar months @@ -13,13 +13,8 @@ export function differenceInCalendarMonths( ): number { const ethiopicLeft = toEthiopicDate(dateLeft); const ethiopicRight = toEthiopicDate(dateRight); - const leapDays = Array.from( - { length: ethiopicLeft.year - ethiopicRight.year }, - (_, i) => ethiopicRight.year + i - ).filter(isEthiopicLeapYear).length; return ( - (ethiopicLeft.year - ethiopicRight.year) * 12 + - (ethiopicLeft.month - ethiopicRight.month) + - leapDays + (ethiopicLeft.year - ethiopicRight.year) * 13 + + (ethiopicLeft.month - ethiopicRight.month) ); } diff --git a/src/ethiopic/lib/eachMonthOfInterval.test.ts b/src/ethiopic/lib/eachMonthOfInterval.test.ts index 7cadbf2d7..92a414b64 100644 --- a/src/ethiopic/lib/eachMonthOfInterval.test.ts +++ b/src/ethiopic/lib/eachMonthOfInterval.test.ts @@ -1,3 +1,90 @@ -test.todo( - "should return an array of dates representing the start of each month in the interval" -); +import { toGregorianDate } from "../utils"; + +import { eachMonthOfInterval } from "./eachMonthOfInterval"; + +describe("eachMonthOfInterval in Ethiopian calendar", () => { + test("should return an array of dates representing the start of each month in the interval", () => { + const start = toGregorianDate({ + year: 2016, + month: 4, + day: 1 + }); // January 1, 2024 (2016-04-01 E.C.) + const end = toGregorianDate({ + year: 2016, + month: 6, + day: 30 + }); // March 9, 2024 (2016-06-30 E.C.) + + const result = eachMonthOfInterval({ start, end }); + + expect(result).toEqual([ + toGregorianDate({ year: 2016, month: 4, day: 1 }), // 2016-04-01 E.C. + toGregorianDate({ year: 2016, month: 5, day: 1 }), // 2016-05-01 E.C. + toGregorianDate({ year: 2016, month: 6, day: 1 }) // 2016-06-01 E.C. + ]); + }); + + test("should handle intervals spanning Ethiopian new year", () => { + const start = toGregorianDate({ + year: 2015, + month: 13, + day: 1 + }); // September 1, 2023 (2015-13-01 E.C.) + const end = toGregorianDate({ + year: 2016, + month: 2, + day: 30 + }); // October 15, 2023 (2016-02-30 E.C.) + + const result = eachMonthOfInterval({ start, end }); + + expect(result).toEqual([ + toGregorianDate({ year: 2015, month: 13, day: 1 }), // 2015-13-01 E.C. + toGregorianDate({ year: 2016, month: 1, day: 1 }), // 2016-01-01 E.C. + toGregorianDate({ year: 2016, month: 2, day: 1 }) // 2016-02-01 E.C. + ]); + }); + + test("should handle single month intervals", () => { + const start = toGregorianDate({ + year: 2016, + month: 4, + day: 1 + }); // 2016-04-01 E.C. + const end = toGregorianDate({ + year: 2016, + month: 4, + day: 30 + }); // 2016-04-30 E.C. + + const result = eachMonthOfInterval({ start, end }); + + expect(result).toEqual([ + toGregorianDate({ year: 2016, month: 4, day: 1 }) // 2016-04-01 E.C. + ]); + }); + + test("should handle intervals spanning multiple years", () => { + const start = toGregorianDate({ + year: 2016, + month: 10, + day: 1 + }); // 2016-01-01 E.C. + const end = toGregorianDate({ + year: 2017, + month: 2, + day: 5 + }); // 2016-13-05 E.C. + + const result = eachMonthOfInterval({ start, end }); + + // Should return 13 dates (one for each Ethiopian month in the year) + expect(result).toHaveLength(6); + expect(result[0]).toEqual( + toGregorianDate({ year: 2016, month: 10, day: 1 }) + ); // First month + expect(result[5]).toEqual( + toGregorianDate({ year: 2017, month: 2, day: 1 }) + ); // Pagume + }); +}); diff --git a/src/ethiopic/lib/eachMonthOfInterval.ts b/src/ethiopic/lib/eachMonthOfInterval.ts index 2acd82d2e..f56d6bc0a 100644 --- a/src/ethiopic/lib/eachMonthOfInterval.ts +++ b/src/ethiopic/lib/eachMonthOfInterval.ts @@ -28,7 +28,7 @@ export function eachMonthOfInterval(interval: Interval): Date[] { ); currentMonth++; - if (currentMonth > 12) { + if (currentMonth > 13) { currentMonth = 1; currentYear++; } diff --git a/src/ethiopic/lib/endOfMonth.test.ts b/src/ethiopic/lib/endOfMonth.test.ts index 4fd3d089c..e414c4ed6 100644 --- a/src/ethiopic/lib/endOfMonth.test.ts +++ b/src/ethiopic/lib/endOfMonth.test.ts @@ -1 +1,50 @@ -test.todo("should return the correct end of the month date for a given date"); +import { toEthiopicDate, toGregorianDate } from "../utils"; + +import { endOfMonth } from "./endOfMonth"; + +describe("endOfMonth in Ethiopian calendar", () => { + test("Should return the last day of a 30-day Ethiopian month", () => { + const date = toGregorianDate({ + year: 2016, + month: 4, + day: 15 + }); // Greg: Dec 25, 2023 + const result = endOfMonth(date); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 4, + day: 30 + }); // Greg: Jan 9, 2024 + }); + + test("Should handle Pagume (13th month) correctly in leap and non-leap years", () => { + // Non-leap year (5 days) + const nonLeapDate = toGregorianDate({ + year: 2016, + month: 13, + day: 1 + }); // Greg: Sep 6, 2024 + const nonLeapResult = endOfMonth(nonLeapDate); + const ethNonLeapResult = toEthiopicDate(nonLeapResult); + expect(ethNonLeapResult).toEqual({ + year: 2016, + month: 13, + day: 5 + }); // Greg: Sep 10, 2024 + + // Leap year (6 days) + const leapDate = toGregorianDate({ + year: 2015, + month: 13, + day: 1 + }); // Greg: Sep 6, 2023 + const leapResult = endOfMonth(leapDate); + const ethLeapResult = toEthiopicDate(leapResult); + expect(ethLeapResult).toEqual({ + year: 2015, + month: 13, + day: 6 + }); // Greg: Sep 11, 2023 + }); +}); diff --git a/src/ethiopic/lib/endOfMonth.ts b/src/ethiopic/lib/endOfMonth.ts index dbc8c9a2c..20e085481 100644 --- a/src/ethiopic/lib/endOfMonth.ts +++ b/src/ethiopic/lib/endOfMonth.ts @@ -1,17 +1,15 @@ -import { - toEthiopicDate, - isEthiopicLeapYear, - toGregorianDate -} from "../utils/index.js"; +import { daysInMonth } from "../utils/daysInMonth.js"; +import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; /** - * End of month + * Returns the last day of the Ethiopian month for the given date. * - * @param {Date} date - The original date - * @returns {Date} The end of the month + * @param date - The gregorian date to get the end of month for + * @returns A new gregorian date representing the last day of the Ethiopian + * month */ export function endOfMonth(date: Date): Date { const { year, month } = toEthiopicDate(date); - const daysInMonth = month === 13 ? (isEthiopicLeapYear(year) ? 6 : 5) : 30; - return toGregorianDate({ year, month, day: daysInMonth }); + const day = daysInMonth(month, year); + return toGregorianDate({ year, month, day: day }); } diff --git a/src/ethiopic/lib/endOfWeek.ts b/src/ethiopic/lib/endOfWeek.ts index 696761e92..bc4f4ce95 100644 --- a/src/ethiopic/lib/endOfWeek.ts +++ b/src/ethiopic/lib/endOfWeek.ts @@ -1,20 +1,14 @@ -import { addDays } from "./addDays.js"; +import { endOfWeek as endOfWeekFns, EndOfWeekOptions } from "date-fns"; /** * End of week * * @param {Date} date - The original date - * @param {Object} [options] - The options object - * @param {number} [options.weekStartsOn=0] - The index of the first day of the - * week (0 - Sunday). Default is `0` + * @param {EndOfWeekOptions} [options] - The options object * @returns {Date} The end of the week */ -export function endOfWeek( - date: Date, - options?: { weekStartsOn?: number } -): Date { - const weekStartsOn = options?.weekStartsOn ?? 0; - const day = date.getDay(); - const diff = (7 - day + weekStartsOn - 1) % 7; - return addDays(date, diff); +export function endOfWeek(date: Date, options?: EndOfWeekOptions): Date { + const weekStartsOn = options?.weekStartsOn ?? 0; // Default to Monday (1) + const endOfWeek = endOfWeekFns(date, { weekStartsOn }); + return endOfWeek; } diff --git a/src/ethiopic/lib/endOfYear.test.ts b/src/ethiopic/lib/endOfYear.test.ts index 6b88f61e8..f39c034c1 100644 --- a/src/ethiopic/lib/endOfYear.test.ts +++ b/src/ethiopic/lib/endOfYear.test.ts @@ -1 +1,35 @@ -test.todo("should return the correct end of the year date for a given date"); +import { toEthiopicDate, toGregorianDate } from "../utils"; + +import { endOfYear } from "./endOfYear"; + +describe("endOfYear in Ethiopian calendar", () => { + test("Should return last day of Ethiopian year for non-leap year", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + const result = endOfYear(date); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 13, // Pagume + day: 5 + }); // Greg: Sep 10, 2024 + }); + + test("Should return last day of Ethiopian year for leap year", () => { + const date = toGregorianDate({ + year: 2015, + month: 1, + day: 1 + }); // Greg: Sep 12, 2022 + const result = endOfYear(date); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2015, + month: 13, // Pagume + day: 6 + }); // Greg: Sep 11, 2023 + }); +}); diff --git a/src/ethiopic/lib/format.test.ts b/src/ethiopic/lib/format.test.ts new file mode 100644 index 000000000..f9fe86063 --- /dev/null +++ b/src/ethiopic/lib/format.test.ts @@ -0,0 +1,36 @@ +import { toGregorianDate } from "../utils"; + +import { format } from "./format"; + +describe("format", () => { + test("Should format date in Ethiopian calendar", () => { + // Ethiopian date: 06/07/2016 (dd/mm/yyyy) + const date = toGregorianDate({ year: 2016, month: 7, day: 6 }); + expect(format(date, "yyyy-MM-dd")).toBe("2016-07-06"); + expect(format(date, "d")).toBe("6"); + expect(format(date, "yyyy-MM")).toBe("2016-07"); + }); + + test("Should format Ethiopian month names correctly", () => { + // Ethiopian date: 06 መጋቢት 2016 + const date = toGregorianDate({ year: 2016, month: 7, day: 6 }); + expect(format(date, "LLLL yyyy")).toBe("መጋቢት 2016"); + expect(format(date, "LLLL")).toBe("መጋቢት"); + expect(format(date, "PPP")).toBe(" መጋቢት 6, 2016"); + }); + + test("Should format time components correctly", () => { + // Ethiopian date: 06 መጋቢት 2016, 14:30 + const date = toGregorianDate({ year: 2016, month: 7, day: 6 }); + date.setHours(14, 30); + expect(format(date, "hh:mm a")).toBe("2:30 PM"); + }); + + test("Should format full date with Ethiopian day names", () => { + // Ethiopian date: ዓርብ, 06 መጋቢት 2016 + const date = toGregorianDate({ year: 2016, month: 7, day: 6 }); + expect(format(date, "PPPP")).toBe("ዓርብ, መጋቢት 6, 2016"); + expect(format(date, "cccc")).toBe("ዓርብ"); + expect(format(date, "cccccc")).toBe("ዓ"); + }); +}); diff --git a/src/ethiopic/lib/format.ts b/src/ethiopic/lib/format.ts new file mode 100644 index 000000000..ff7298885 --- /dev/null +++ b/src/ethiopic/lib/format.ts @@ -0,0 +1,109 @@ +import type { FormatOptions as DateFnsFormatOptions } from "date-fns"; + +import type { DateLibOptions } from "../../classes/DateLib.js"; +import { toEthiopicDate } from "../utils/index.js"; + +import { formatNumber } from "./formatNumber.js"; + +/** Options for formatting dates in the Ethiopian calendar */ +export type FormatOptions = DateFnsFormatOptions; + +function getEtDayName(day: Date, short: boolean = true): string { + const dayOfWeek = day.getDay(); + return short ? shortDays[dayOfWeek] : longDays[dayOfWeek]; +} + +function getEtMonthName(m: number): string { + if (m > 0 && m <= 13) { + return ethMonths[m - 1]; + } + return ""; +} + +function formatEthiopianDate( + dateObj: Date | undefined, + formatStr: string +): string { + const etDate = dateObj ? toEthiopicDate(dateObj) : undefined; + + if (!etDate) return ""; + + switch (formatStr) { + case "LLLL yyyy": + case "LLLL y": + return `${getEtMonthName(etDate.month)} ${etDate.year}`; + + case "LLLL": + return getEtMonthName(etDate.month); + + case "yyyy-MM-dd": + return `${etDate.year}-${etDate.month + .toString() + .padStart(2, "0")}-${etDate.day.toString().padStart(2, "0")}`; + + case "yyyy-MM": + return `${etDate.year}-${etDate.month.toString().padStart(2, "0")}`; + + case "d": + return etDate.day.toString(); + case "PPP": + return ` ${getEtMonthName(etDate.month)} ${etDate.day}, ${etDate.year}`; + case "PPPP": + if (!dateObj) return ""; + return `${getEtDayName(dateObj, false)}, ${getEtMonthName(etDate.month)} ${ + etDate.day + }, ${etDate.year}`; + + case "cccc": + return dateObj ? getEtDayName(dateObj, false) : ""; + case "cccccc": + return dateObj ? getEtDayName(dateObj) : ""; + + default: + return `${etDate.day}/${etDate.month}/${etDate.year}`; + } +} + +export function format( + date: Date, + formatStr: string, + options?: DateFnsFormatOptions +): string { + const extendedOptions = options as DateLibOptions; + + if (formatStr.includes("hh:mm") || formatStr.includes("a")) { + return new Intl.DateTimeFormat(extendedOptions?.locale?.code ?? "en-US", { + hour: "numeric", + minute: "numeric", + hour12: formatStr.includes("a") + }).format(date); + } + + const formatted = formatEthiopianDate(date, formatStr); + + if (extendedOptions?.numerals && extendedOptions.numerals === "geez") { + return formatted.replace(/\d+/g, (match) => + formatNumber(parseInt(match), "geez") + ); + } + + return formatted; +} + +export const ethMonths = [ + "መስከረም", + "ጥቅምት", + "ህዳር", + "ታህሳስ", + "ጥር", + "የካቲት", + "መጋቢት", + "ሚያዚያ", + "ግንቦት", + "ሰኔ", + "ሐምሌ", + "ነሀሴ", + "ጳጉሜ" +]; +export const shortDays = ["እ", "ሰ", "ማ", "ረ", "ሐ", "ዓ", "ቅ"]; +export const longDays = ["እሁድ", "ሰኞ", "ማክሰኞ", "ረቡዕ", "ሐሙስ", "ዓርብ", "ቅዳሜ"]; diff --git a/src/ethiopic/lib/formatNumber.test.ts b/src/ethiopic/lib/formatNumber.test.ts new file mode 100644 index 000000000..879c9c2fa --- /dev/null +++ b/src/ethiopic/lib/formatNumber.test.ts @@ -0,0 +1,47 @@ +import { formatNumber } from "./formatNumber"; + +describe("formatNumber", () => { + test("Should format numbers using Ethiopian numerals", () => { + expect(formatNumber(1, "geez")).toBe("፩"); + expect(formatNumber(10, "geez")).toBe("፲"); + expect(formatNumber(12, "geez")).toBe("፲፪"); + expect(formatNumber(21, "geez")).toBe("፳፩"); + expect(formatNumber(100, "geez")).toBe("፻"); + expect(formatNumber(1000, "geez")).toBe("፲፻"); + expect(formatNumber(10000, "geez")).toBe("፼"); + + // years + expect(formatNumber(1998, "geez")).toBe("፲፱፻፺፰"); + expect(formatNumber(2000, "geez")).toBe("፳፻"); + expect(formatNumber(2002, "geez")).toBe("፳፻፪"); + + // Complex numbers + expect(formatNumber(140000, "geez")).toBe("፲፬፼"); + expect(formatNumber(123, "geez")).toBe("፻፳፫"); + expect(formatNumber(1234, "geez")).toBe("፲፪፻፴፬"); + expect(formatNumber(38965, "geez")).toBe("፫፼፹፱፻፷፭"); + }); + + test("Should format numbers using latin numerals by default", () => { + expect(formatNumber(123)).toBe("123"); + expect(formatNumber(1234)).toBe("1,234"); + expect(formatNumber(12345)).toBe("12,345"); + + // Explicit latin format + expect(formatNumber(123, "latn")).toBe("123"); + expect(formatNumber(1234, "latn")).toBe("1,234"); + expect(formatNumber(12345, "latn")).toBe("12,345"); + }); + + test("Should handle zero and negative numbers correctly", () => { + // Zero + expect(formatNumber(0, "geez")).toBe("-"); // Special case in Geez + expect(formatNumber(0, "latn")).toBe("0"); + + // Negative numbers + expect(formatNumber(-123, "geez")).toBe("-፻፳፫"); + expect(formatNumber(-1234, "geez")).toBe("-፲፪፻፴፬"); + expect(formatNumber(-123, "latn")).toBe("-123"); + expect(formatNumber(-1234, "latn")).toBe("-1,234"); + }); +}); diff --git a/src/ethiopic/lib/formatNumber.ts b/src/ethiopic/lib/formatNumber.ts new file mode 100644 index 000000000..ccfe1325d --- /dev/null +++ b/src/ethiopic/lib/formatNumber.ts @@ -0,0 +1,33 @@ +import { toGeezNumerals } from "../utils/geezConverter.js"; + +/** + * Formats a number using either Latin or Ethiopic (Geez) numerals + * + * @example + * ```ts + * formatNumber(123) // '123' + * formatNumber(123, 'geez') // '፻፳፫' + * formatNumber(2023, 'geez') // '፳፻፳፫' + * ```; + * + * @param value - The number to format + * @param numerals - The numeral system to use: + * + * - 'latn': Latin numerals (1, 2, 3...) + * - 'geez': Ethiopic numerals (፩, ፪, ፫...) + * + * @returns The formatted number string + */ +export function formatNumber(value: number, numerals: string = "latn"): string { + console.log("numerals", value); + if (numerals === "geez") { + return toGeezNumerals(value); + } + + // Use Intl.NumberFormat for other numeral systems + const formatter = new Intl.NumberFormat("en-US", { + numberingSystem: numerals + }); + + return formatter.format(value); +} diff --git a/src/ethiopic/lib/getMonth.test.ts b/src/ethiopic/lib/getMonth.test.ts index addfd9364..bb5f40d0e 100644 --- a/src/ethiopic/lib/getMonth.test.ts +++ b/src/ethiopic/lib/getMonth.test.ts @@ -1 +1,24 @@ -test.todo("should return the correct zero-based month index for a given date"); +import { toGregorianDate } from "../utils"; + +import { getMonth } from "./getMonth"; + +describe("getMonth in Ethiopian calendar", () => { + test("should return 0-based month number", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, // Meskerem + day: 1 + }); // Greg: Sep 12, 2023 + + expect(getMonth(date)).toBe(0); + }); + + test("should handle Pagume (13th month)", () => { + const date = toGregorianDate({ + year: 2016, + month: 13, // Pagume + day: 1 + }); // Greg: Sep 6, 2024 + expect(getMonth(date)).toBe(12); + }); +}); diff --git a/src/ethiopic/lib/getWeek.test.ts b/src/ethiopic/lib/getWeek.test.ts index f3a007310..698494045 100644 --- a/src/ethiopic/lib/getWeek.test.ts +++ b/src/ethiopic/lib/getWeek.test.ts @@ -1 +1,59 @@ -test.todo("should return the correct week number for a given date"); +import { toGregorianDate } from "../utils"; + +import { getWeek } from "./getWeek"; + +describe("getWeek in Ethiopian calendar", () => { + test("should return 1 for first week of year", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + expect(getWeek(date)).toBe(1); + }); + + test("should handle dates in last month of year to be either 52 or 1", () => { + // part of the last week of the previous year + const date52 = toGregorianDate({ + year: 2016, + month: 13, + day: 1 + }); // Greg: Sep 10, 202 + expect(getWeek(date52)).toBe(52); + + // part of the first week of the new year + const date1 = toGregorianDate({ + year: 2016, + month: 13, + day: 5 + }); // Greg: Sep 10, 202 + expect(getWeek(date1)).toBe(1); + }); + + test("should handle dates at week boundaries", () => { + // Sunday week 1 + const sunday = toGregorianDate({ + year: 2016, + month: 1, + day: 6 + }); // Greg: Sep 17, 2023 + expect(getWeek(sunday)).toBe(1); + + // Monday week 2 + const monday = toGregorianDate({ + year: 2016, + month: 1, + day: 7 + }); // Greg: Sep 18, 2023 + expect(getWeek(monday)).toBe(2); + }); + + test("should handle dates in middle of year", () => { + const midYear = toGregorianDate({ + year: 2016, + month: 6, + day: 15 + }); // Greg: Feb 23, 2024 + expect(getWeek(midYear)).toBe(24); + }); +}); diff --git a/src/ethiopic/lib/getWeek.ts b/src/ethiopic/lib/getWeek.ts index 6032b828a..04aef487c 100644 --- a/src/ethiopic/lib/getWeek.ts +++ b/src/ethiopic/lib/getWeek.ts @@ -1,26 +1,40 @@ +import { + differenceInDays, + getWeek as getWeekFns, + GetWeekOptions +} from "date-fns"; + import { toGregorianDate, toEthiopicDate } from "../utils/index.js"; -import { differenceInCalendarDays } from "./differenceInCalendarDays.js"; +import { startOfWeek } from "./startOfWeek.js"; /** - * Get week + * Get week number for Ethiopian calendar * * @param {Date} date - The original date - * @param {Object} [options] - The options object - * @param {number} [options.weekStartsOn=0] - The index of the first day of the - * week (0 - Sunday). Default is `0` + * @param {GetWeekOptions} [options] - The options object * @returns {number} The week number */ -export function getWeek( - date: Date, - options?: { weekStartsOn?: number } -): number { - const weekStartsOn = options?.weekStartsOn ?? 0; // Default to Sunday - const startOfYear = toGregorianDate({ - year: toEthiopicDate(date).year, +export function getWeek(date: Date, options?: GetWeekOptions): number { + const weekStartsOn = options?.weekStartsOn ?? 1; // Default to Tuesday for Ethiopian calendar + const etDate = toEthiopicDate(date); + const currentWeekStart = startOfWeek(date, { weekStartsOn }); + + // Get the first day of the current year + const firstDayOfYear = toGregorianDate({ + year: etDate.year, month: 1, day: 1 }); - const diffInDays = differenceInCalendarDays(date, startOfYear); - return Math.floor((diffInDays + weekStartsOn) / 7) + 1; + + const firstWeekStart = startOfWeek(firstDayOfYear, { weekStartsOn }); + + // If date is before the first week of its year + if (date < firstWeekStart) { + return getWeekFns(date, { weekStartsOn, firstWeekContainsDate: 1 }); + } + + // Calculate week number based on days since first week + const daysSinceFirstWeek = differenceInDays(currentWeekStart, firstWeekStart); + return Math.floor(daysSinceFirstWeek / 7) + 1; } diff --git a/src/ethiopic/lib/getYear.test.ts b/src/ethiopic/lib/getYear.test.ts index 32fb406fc..a922a89d4 100644 --- a/src/ethiopic/lib/getYear.test.ts +++ b/src/ethiopic/lib/getYear.test.ts @@ -1 +1,32 @@ -test.todo("should return the correct Ethiopic year for a given date"); +import { toGregorianDate } from "../utils"; + +import { getYear } from "./getYear"; + +describe("getYear in Ethiopian calendar", () => { + test("Should return correct Ethiopian year", () => { + const date = toGregorianDate({ + year: 2016, + month: 4, + day: 15 + }); // Greg: Dec 25, 2023 + expect(getYear(date)).toBe(2016); + }); + + test("Should handle Ethiopian year boundary correctly", () => { + // Last day of Ethiopian year 2015 + const lastDay2015 = toGregorianDate({ + year: 2015, + month: 13, + day: 6 + }); // Greg: Sep 11, 2023 + expect(getYear(lastDay2015)).toBe(2015); + + // First day of Ethiopian year 2016 + const firstDay2016 = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + expect(getYear(firstDay2016)).toBe(2016); + }); +}); diff --git a/src/ethiopic/lib/index.ts b/src/ethiopic/lib/index.ts index 0809e9e8a..88a2219f1 100644 --- a/src/ethiopic/lib/index.ts +++ b/src/ethiopic/lib/index.ts @@ -8,10 +8,11 @@ export * from "./eachMonthOfInterval.js"; export * from "./endOfMonth.js"; export * from "./endOfWeek.js"; export * from "./endOfYear.js"; +export * from "./format.js"; +export * from "./formatNumber.js"; export * from "./getMonth.js"; export * from "./getWeek.js"; export * from "./getYear.js"; -export * from "./isSameDay.js"; export * from "./isSameMonth.js"; export * from "./isSameYear.js"; export * from "./newDate.js"; diff --git a/src/ethiopic/lib/isAfter.ts b/src/ethiopic/lib/isAfter.ts new file mode 100644 index 000000000..c5e93022a --- /dev/null +++ b/src/ethiopic/lib/isAfter.ts @@ -0,0 +1,5 @@ +import { isAfter as isAfterNative } from "date-fns"; + +export function isAfter(date: Date, other: Date): boolean { + return isAfterNative(date, other); +} diff --git a/src/ethiopic/lib/isSameDay.test.ts b/src/ethiopic/lib/isSameDay.test.ts deleted file mode 100644 index 4363ac2fd..000000000 --- a/src/ethiopic/lib/isSameDay.test.ts +++ /dev/null @@ -1 +0,0 @@ -test.todo("isSameDay should return true if two dates are on the same day"); diff --git a/src/ethiopic/lib/isSameDay.ts b/src/ethiopic/lib/isSameDay.ts deleted file mode 100644 index 90364abd9..000000000 --- a/src/ethiopic/lib/isSameDay.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { toEthiopicDate } from "../utils/index.js"; - -/** - * Is same day - * - * @param {Date} dateLeft - The first date - * @param {Date} dateRight - The second date - * @returns {boolean} True if the two dates are on the same day - */ -export function isSameDay(dateLeft: Date, dateRight: Date): boolean { - const left = toEthiopicDate(dateLeft); - const right = toEthiopicDate(dateRight); - return ( - left.year === right.year && - left.month === right.month && - left.day === right.day - ); -} diff --git a/src/ethiopic/lib/isSameMonth.test.ts b/src/ethiopic/lib/isSameMonth.test.ts index d5355b629..79cb1659c 100644 --- a/src/ethiopic/lib/isSameMonth.test.ts +++ b/src/ethiopic/lib/isSameMonth.test.ts @@ -1 +1,48 @@ -test.todo("isSameMonth should return true if two dates are in the same month"); +import { toGregorianDate } from "../utils"; + +import { isSameMonth } from "./isSameMonth"; + +describe("isSameMonth in Ethiopian calendar", () => { + test("Should return true for dates in same Ethiopian month", () => { + const date1 = toGregorianDate({ + year: 2016, + month: 4, + day: 1 + }); // Greg: Dec 11, 2023 + const date2 = toGregorianDate({ + year: 2016, + month: 4, + day: 30 + }); // Greg: Jan 9, 2024 + expect(isSameMonth(date1, date2)).toBe(true); + }); + + test("Should return false for dates in different Ethiopian months", () => { + const date1 = toGregorianDate({ + year: 2016, + month: 4, + day: 30 + }); // Greg: Jan 9, 2024 + const date2 = toGregorianDate({ + year: 2016, + month: 5, + day: 1 + }); // Greg: Jan 10, 2024 + expect(isSameMonth(date1, date2)).toBe(false); + }); + + test("Should handle Ethiopian month boundaries correctly", () => { + // Same month number but different years + const date1 = toGregorianDate({ + year: 2015, + month: 13, + day: 6 + }); // Greg: Sep 11, 2023 + const date2 = toGregorianDate({ + year: 2016, + month: 13, + day: 5 + }); // Greg: Sep 10, 2024 + expect(isSameMonth(date1, date2)).toBe(false); + }); +}); diff --git a/src/ethiopic/lib/isSameYear.test.ts b/src/ethiopic/lib/isSameYear.test.ts index a5bc214df..b0e6b7f51 100644 --- a/src/ethiopic/lib/isSameYear.test.ts +++ b/src/ethiopic/lib/isSameYear.test.ts @@ -1 +1,47 @@ -test.todo("isSameYear should return true if two dates are in the same year"); +import { toGregorianDate } from "../utils"; + +import { isSameYear } from "./isSameYear"; + +describe("isSameYear in Ethiopian calendar", () => { + test("Should return true for dates in same Ethiopian year", () => { + const date1 = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + const date2 = toGregorianDate({ + year: 2016, + month: 13, + day: 5 + }); // Greg: Sep 10, 2024 + expect(isSameYear(date1, date2)).toBe(true); + }); + + test("Should return false for dates in different Ethiopian years", () => { + const date1 = toGregorianDate({ + year: 2015, + month: 6, + day: 15 + }); // Greg: Feb 23, 2023 + const date2 = toGregorianDate({ + year: 2016, + month: 6, + day: 15 + }); // Greg: Feb 23, 2024 + expect(isSameYear(date1, date2)).toBe(false); + }); + + test("Should handle Ethiopian year boundary correctly", () => { + const lastDayOf2015 = toGregorianDate({ + year: 2015, + month: 13, + day: 6 + }); // Greg: Sep 11, 2023 + const firstDayOf2016 = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + expect(isSameYear(lastDayOf2015, firstDayOf2016)).toBe(false); + }); +}); diff --git a/src/ethiopic/lib/isSameYear.ts b/src/ethiopic/lib/isSameYear.ts index 28c146e44..58d9fae9f 100644 --- a/src/ethiopic/lib/isSameYear.ts +++ b/src/ethiopic/lib/isSameYear.ts @@ -1,11 +1,11 @@ import { toEthiopicDate } from "../utils/index.js"; /** - * Is same year + * Checks if two dates fall in the same Ethiopian year. * - * @param {Date} dateLeft - The first date - * @param {Date} dateRight - The second date - * @returns {boolean} True if the two dates are in the same year + * @param dateLeft - The first gregorian date to compare + * @param dateRight - The second gregorian date to compare + * @returns True if the dates are in the same Ethiopian year */ export function isSameYear(dateLeft: Date, dateRight: Date): boolean { const left = toEthiopicDate(dateLeft); diff --git a/src/ethiopic/lib/newDate.test.ts b/src/ethiopic/lib/newDate.test.ts index d798aaad6..fb836a51b 100644 --- a/src/ethiopic/lib/newDate.test.ts +++ b/src/ethiopic/lib/newDate.test.ts @@ -1,3 +1,48 @@ -describe("newDate", () => { - test.todo("should create a new Ethiopic date"); +import { toEthiopicDate } from "../utils/index.js"; + +import { newDate } from "./newDate"; + +describe("newDate in Ethiopian calendar", () => { + test("creates date with valid Ethiopian values", () => { + const date = newDate(2016, 4, 15); // Tahsas 15, 2016 + const ethDate = toEthiopicDate(date); + expect(ethDate).toEqual({ + year: 2016, + month: 5, // Tahsas + day: 15 + }); // Greg: Dec 25, 2023 + }); + + test("handles Pagume (13th month)", () => { + // Non-leap year Pagume + const nonLeapDate = newDate(2016, 12, 5); + const ethNonLeapDate = toEthiopicDate(nonLeapDate); + expect(ethNonLeapDate).toEqual({ + year: 2016, + month: 13, // Pagume + day: 5 + }); // Greg: Sep 10, 2024 + + // Leap year Pagume + const leapDate = newDate(2015, 12, 6); + const ethLeapDate = toEthiopicDate(leapDate); + expect(ethLeapDate).toEqual({ + year: 2015, + month: 13, // Pagume + day: 6 + }); // Greg: Sep 11, 2023 + }); + + test("throws error for invalid month and day", () => { + //invalid month + expect(() => newDate(2016, 14, 1)).toThrow(); + //invalid day + expect(() => newDate(2016, 13, 7)).toThrow(); + //invalid month and day + expect(() => newDate(2016, 5, 32)).toThrow(); + //invalid month and day + expect(() => newDate(2016, -1, -1)).toThrow(); + //not a leap year + expect(() => newDate(2016, 13, 6)).toThrow(); + }); }); diff --git a/src/ethiopic/lib/newDate.ts b/src/ethiopic/lib/newDate.ts index c494ccefc..cc7d1c769 100644 --- a/src/ethiopic/lib/newDate.ts +++ b/src/ethiopic/lib/newDate.ts @@ -1,4 +1,5 @@ import { toGregorianDate } from "../utils/index.js"; +import { isEthiopicDateValid } from "../utils/isEthiopicDateValid.js"; /** * Creates a new Ethiopic date @@ -9,5 +10,15 @@ import { toGregorianDate } from "../utils/index.js"; * @returns {Date} The corresponding Gregorian date */ export function newDate(year: number, monthIndex: number, date: number): Date { - return toGregorianDate({ year, month: monthIndex + 1, day: date }); + // Convert from 0-based month index to 1-based Ethiopic month + const month = monthIndex + 1; + + if (!isEthiopicDateValid({ year, month, day: date })) { + throw new Error("Invalid Ethiopic date"); + } + return toGregorianDate({ + year: year, + month: month, + day: date + }); } diff --git a/src/ethiopic/lib/setMonth.test.ts b/src/ethiopic/lib/setMonth.test.ts index 1004cfcca..05beb2a30 100644 --- a/src/ethiopic/lib/setMonth.test.ts +++ b/src/ethiopic/lib/setMonth.test.ts @@ -1,3 +1,56 @@ -describe("setMonth", () => { - test.todo("set the Ethiopic month for a given date"); +import { toGregorianDate } from "../utils"; + +import { setMonth } from "./setMonth"; + +describe("setMonth in Ethiopian calendar", () => { + test("should set month to a regular month", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, // Meskerem + day: 1 + }); // Greg: Sep 12, 2023 + + const result = setMonth(date, 3); // Set to 4th month (Tahsas) + expect(result).toEqual( + toGregorianDate({ + year: 2016, + month: 4, + day: 1 + }) + ); + }); + + test("should handle setting to Pagume (13th month)", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + + const result = setMonth(date, 12); // Set to Pagume (0-based index) + expect(result).toEqual( + toGregorianDate({ + year: 2016, + month: 13, + day: 1 + }) + ); + }); + + test("should preserve the day when setting month", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, + day: 15 + }); // Greg: Sep 26, 2023 + + const result = setMonth(date, 5); // Set to 6th month (Yekatit) + expect(result).toEqual( + toGregorianDate({ + year: 2016, + month: 6, + day: 15 + }) + ); + }); }); diff --git a/src/ethiopic/lib/setYear.test.ts b/src/ethiopic/lib/setYear.test.ts index cb6524ee8..6ffd0b4a2 100644 --- a/src/ethiopic/lib/setYear.test.ts +++ b/src/ethiopic/lib/setYear.test.ts @@ -1,3 +1,65 @@ -describe("setYear", () => { - test.todo("set the Ethiopic year for a given date"); +import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; + +import { setYear } from "./setYear"; + +describe("setYear in Ethiopian calendar", () => { + test("sets year correctly", () => { + const date = toGregorianDate({ + year: 2015, + month: 4, + day: 15 + }); // Greg: Dec 25, 2022 + const result = setYear(date, 2016); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 4, + day: 15 + }); // Greg: Dec 25, 2023 + }); + + test("maintains month and day when possible", () => { + const date = toGregorianDate({ + year: 2015, + month: 7, + day: 21 + }); // Greg: Mar 30, 2023 + const result = setYear(date, 2016); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 7, + day: 21 + }); // Greg: Mar 29, 2024 + }); + + test("adjusts day for leap year changes", () => { + // From leap year to non-leap year (Pagume 6 -> Pagume 5) + const leapDate = toGregorianDate({ + year: 2015, + month: 13, + day: 6 + }); // Greg: Sep 11, 2023 + const nonLeapResult = setYear(leapDate, 2016); + const ethNonLeapResult = toEthiopicDate(nonLeapResult); + expect(ethNonLeapResult).toEqual({ + year: 2016, + month: 13, + day: 5 + }); // Greg: Sep 10, 2024 + + // From non-leap year to leap year (maintains Pagume 5) + const nonLeapDate = toGregorianDate({ + year: 2016, + month: 13, + day: 5 + }); // Greg: Sep 10, 2024 + const leapResult = setYear(nonLeapDate, 2015); + const ethLeapResult = toEthiopicDate(leapResult); + expect(ethLeapResult).toEqual({ + year: 2015, + month: 13, + day: 5 + }); // Greg: Sep 10, 2023 + }); }); diff --git a/src/ethiopic/lib/setYear.ts b/src/ethiopic/lib/setYear.ts index e86877735..2af2e86c3 100644 --- a/src/ethiopic/lib/setYear.ts +++ b/src/ethiopic/lib/setYear.ts @@ -1,3 +1,4 @@ +import { daysInMonth } from "../utils/daysInMonth.js"; import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; /** @@ -9,5 +10,10 @@ import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; */ export function setYear(date: Date, year: number): Date { const { month, day } = toEthiopicDate(date); - return toGregorianDate({ year, month, day }); + + // Check if the day is valid in the new year (handles leap year changes) + const maxDays = daysInMonth(month, year); + const newDay = Math.min(day, maxDays); + + return toGregorianDate({ year, month, day: newDay }); } diff --git a/src/ethiopic/lib/startOfDay.test.ts b/src/ethiopic/lib/startOfDay.test.ts index 195e1963b..fc91529f5 100644 --- a/src/ethiopic/lib/startOfDay.test.ts +++ b/src/ethiopic/lib/startOfDay.test.ts @@ -1,3 +1,71 @@ -describe("startOfDay", () => { - test.todo("should return the start of the Ethiopic day for a given date"); +import { toGregorianDate } from "../utils"; + +import { startOfDay } from "./startOfDay"; + +describe("startOfDay in Ethiopian calendar", () => { + test("should return the start of the Ethiopic day for a given date", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + date.setHours(12, 34, 56, 789); // Add time components + + const result = startOfDay(date); + + // Should preserve the date but reset time to start of day + expect(result).toEqual( + toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }) + ); + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + expect(result.getSeconds()).toBe(0); + expect(result.getMilliseconds()).toBe(0); + }); + + test("should handle dates with time near midnight", () => { + const date = toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }); + date.setHours(23, 59, 59, 999); + + const result = startOfDay(date); + + expect(result).toEqual( + toGregorianDate({ + year: 2016, + month: 1, + day: 1 + }) + ); + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + expect(result.getSeconds()).toBe(0); + expect(result.getMilliseconds()).toBe(0); + }); + + test("should preserve the original date components", () => { + const date = toGregorianDate({ + year: 2016, + month: 13, // Pagume + day: 5 + }); // Greg: Sep 10, 2024 + date.setHours(15, 30, 45, 500); + + const result = startOfDay(date); + + expect(result).toEqual( + toGregorianDate({ + year: 2016, + month: 13, + day: 5 + }) + ); + }); }); diff --git a/src/ethiopic/lib/startOfMonth.test.ts b/src/ethiopic/lib/startOfMonth.test.ts index b9e725910..5a45c6274 100644 --- a/src/ethiopic/lib/startOfMonth.test.ts +++ b/src/ethiopic/lib/startOfMonth.test.ts @@ -1 +1,50 @@ -test.todo("startOfMonth should return the start of the Ethiopic month"); +import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; + +import { startOfMonth } from "./startOfMonth"; + +describe("startOfMonth in Ethiopian calendar", () => { + test("returns first day of Ethiopian month", () => { + const date = toGregorianDate({ + year: 2016, + month: 4, + day: 15 + }); // Greg: Dec 25, 2023 + const result = startOfMonth(date); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 4, + day: 1 + }); // Greg: Dec 11, 2023 + }); + + test("maintains year and month", () => { + const date = toGregorianDate({ + year: 2016, + month: 7, + day: 30 + }); // Greg: Apr 7, 2024 + const result = startOfMonth(date); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 7, + day: 1 + }); // Greg: Mar 10, 2024 + }); + + test("handles Pagume (13th month)", () => { + const date = toGregorianDate({ + year: 2015, + month: 13, + day: 6 + }); // Greg: Sep 11, 2023 + const result = startOfMonth(date); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2015, + month: 13, + day: 1 + }); // Greg: Sep 6, 2023 + }); +}); diff --git a/src/ethiopic/lib/startOfWeek.test.ts b/src/ethiopic/lib/startOfWeek.test.ts index 37eb654e9..b1f0484f5 100644 --- a/src/ethiopic/lib/startOfWeek.test.ts +++ b/src/ethiopic/lib/startOfWeek.test.ts @@ -1 +1,38 @@ -test.todo("startOfWeek should return the start of the week"); +import { startOfWeek } from "./startOfWeek"; + +describe("startOfWeek", () => { + test("should return Monday when given a Monday", () => { + const monday = new Date(2024, 2, 18); // Monday, March 18, 2024 + const result = startOfWeek(monday); + expect(result.getDay()).toBe(1); // Monday is 1 + expect(result.toISOString()).toBe(monday.toISOString()); + }); + + test("should return previous Monday when given a Wednesday", () => { + const wednesday = new Date(2024, 2, 20); // Wednesday, March 20, 2024 + const result = startOfWeek(wednesday); + const expected = new Date(2024, 2, 18); // Should return Monday, March 18, 2024 + expect(result.toISOString()).toBe(expected.toISOString()); + }); + + test("should return previous Monday when given a Sunday", () => { + const sunday = new Date(2024, 2, 24); // Sunday, March 24, 2024 + const result = startOfWeek(sunday); + const expected = new Date(2024, 2, 18); // Should return Monday, March 18, 2024 + expect(result.toISOString()).toBe(expected.toISOString()); + }); + + test("should handle month boundaries correctly", () => { + const saturday = new Date(2024, 2, 2); // Saturday, March 2, 2024 + const result = startOfWeek(saturday); + const expected = new Date(2024, 1, 26); // Should return Monday, February 26, 2024 + expect(result.toISOString()).toBe(expected.toISOString()); + }); + + test("should handle year boundaries correctly", () => { + const wednesday = new Date(2024, 0, 3); // Wednesday, January 3, 2024 + const result = startOfWeek(wednesday); + const expected = new Date(2024, 0, 1); // Should return Monday, January 1, 2024 + expect(result.toISOString()).toBe(expected.toISOString()); + }); +}); diff --git a/src/ethiopic/lib/startOfWeek.ts b/src/ethiopic/lib/startOfWeek.ts index ebf774cec..443c1e818 100644 --- a/src/ethiopic/lib/startOfWeek.ts +++ b/src/ethiopic/lib/startOfWeek.ts @@ -1,12 +1,13 @@ -import { addDays } from "./addDays.js"; +import { StartOfWeekOptions, startOfWeek as startOfWeekFns } from "date-fns"; /** * Start of week * * @param {Date} date - The original date + * @param {StartOfWeekOptions} [options] - The options object * @returns {Date} The start of the week */ -export function startOfWeek(date: Date): Date { - const day = date.getDay(); - return addDays(date, -day); // Subtract days to get to Sunday (start of week) +export function startOfWeek(date: Date, options?: StartOfWeekOptions): Date { + const weekStartsOn = options?.weekStartsOn ?? 1; // Default to Monday (1) + return startOfWeekFns(date, { weekStartsOn: weekStartsOn }); } diff --git a/src/ethiopic/lib/startOfYear.test.ts b/src/ethiopic/lib/startOfYear.test.ts index 7031f7887..c99dd4c9a 100644 --- a/src/ethiopic/lib/startOfYear.test.ts +++ b/src/ethiopic/lib/startOfYear.test.ts @@ -1 +1,50 @@ -test.todo("startOfYear should return the start of the Ethiopic year"); +import { toEthiopicDate, toGregorianDate } from "../utils/index.js"; + +import { startOfYear } from "./startOfYear"; + +describe("startOfYear in Ethiopian calendar", () => { + test("returns first day of Ethiopian year", () => { + const date = toGregorianDate({ + year: 2016, + month: 6, + day: 15 + }); // Greg: Feb 23, 2024 + const result = startOfYear(date); + const ethResult = toEthiopicDate(result); + expect(ethResult).toEqual({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + }); + + test("handles leap year correctly", () => { + // From middle of leap year + const leapDate = toGregorianDate({ + year: 2015, + month: 7, + day: 15 + }); // Greg: Mar 24, 2023 + const leapResult = startOfYear(leapDate); + const ethLeapResult = toEthiopicDate(leapResult); + expect(ethLeapResult).toEqual({ + year: 2015, + month: 1, + day: 1 + }); // Greg: Sep 12, 2022 + + // From middle of non-leap year + const nonLeapDate = toGregorianDate({ + year: 2016, + month: 7, + day: 15 + }); // Greg: Mar 24, 2024 + const nonLeapResult = startOfYear(nonLeapDate); + const ethNonLeapResult = toEthiopicDate(nonLeapResult); + expect(ethNonLeapResult).toEqual({ + year: 2016, + month: 1, + day: 1 + }); // Greg: Sep 12, 2023 + }); +}); diff --git a/src/ethiopic/utils/EthiopicDate.ts b/src/ethiopic/utils/EthiopicDate.ts index ae2ea143e..4f490c2bb 100644 --- a/src/ethiopic/utils/EthiopicDate.ts +++ b/src/ethiopic/utils/EthiopicDate.ts @@ -1,5 +1,17 @@ +/** + * Represents a date in the Ethiopic calendar system. + * + * The Ethiopic calendar has: + * + * - 13 months + * - 12 months of 30 days each + * - A 13th month (Pagume) of 5 or 6 days + */ export interface EthiopicDate { + /** The Ethiopic year */ year: number; + /** The month number (1-13) */ month: number; + /** The day of the month (1-30, or 1-5/6 for month 13) */ day: number; } diff --git a/src/ethiopic/utils/consts.ts b/src/ethiopic/utils/consts.ts deleted file mode 100644 index c8e6f9069..000000000 --- a/src/ethiopic/utils/consts.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Julian Day number offset for the Ethiopic calendar */ -export const ETHIOPIC_EPOCH_OFFSET = -285019; - -// TODO: Add more constants diff --git a/src/ethiopic/utils/daysInMonth.ts b/src/ethiopic/utils/daysInMonth.ts new file mode 100644 index 000000000..16ee608a9 --- /dev/null +++ b/src/ethiopic/utils/daysInMonth.ts @@ -0,0 +1,20 @@ +import { isEthiopicLeapYear } from "./isEthiopicLeapYear.js"; + +/** + * Returns the number of days in the specified month of the Ethiopic calendar. + * + * In the Ethiopic calendar: + * + * - Months 1-12 have 30 days each + * - Month 13 (Pagume) has 5 days in regular years, 6 days in leap years + * + * @param month - The month number (1-13) + * @param year - The Ethiopic year + * @returns The number of days in the specified month + */ +export function daysInMonth(month: number, year: number): number { + if (month === 13) { + return isEthiopicLeapYear(year) ? 6 : 5; + } + return 30; +} diff --git a/src/ethiopic/utils/geezConverter.ts b/src/ethiopic/utils/geezConverter.ts new file mode 100644 index 000000000..1ad62c9f5 --- /dev/null +++ b/src/ethiopic/utils/geezConverter.ts @@ -0,0 +1,53 @@ +/** + * Converts a number to Geez (Ethiopic) numerals. + * + * @param num - The number to convert + * @returns The number in Geez numerals + * @throws {Error} When input is 0 (Geez has no zero representation) + */ +export function toGeezNumerals(num: number): string { + const geezDigits = ["፩", "፪", "፫", "፬", "፭", "፮", "፯", "፰", "፱"]; + const geezTens = ["፲", "፳", "፴", "፵", "፶", "፷", "፸", "፹", "፺"]; + const geezHundreds = "፻"; + const geezThousands = "፼"; + + if (num === 0) return "-"; + if (num < 0) return `-${toGeezNumerals(-num)}`; + + let result = ""; + let remaining = num; + + // Handle thousands (10,000 and above) + if (remaining >= 10000) { + const thousandsValue = Math.floor(remaining / 10000); + result += + thousandsValue === 1 + ? geezThousands + : toGeezNumerals(thousandsValue) + geezThousands; + remaining %= 10000; + } + + // Handle hundreds (100 - 9,900) + if (remaining >= 100) { + const hundredsValue = Math.floor(remaining / 100); + result += + hundredsValue === 1 + ? geezHundreds + : toGeezNumerals(hundredsValue) + geezHundreds; + remaining %= 100; + } + + // Handle tens (10 - 90) + if (remaining >= 10) { + const tensValue = Math.floor(remaining / 10); + result += geezTens[tensValue - 1]; + remaining %= 10; + } + + // Handle ones (1 - 9) + if (remaining > 0) { + result += geezDigits[remaining - 1]; + } + + return result; +} diff --git a/src/ethiopic/utils/index.ts b/src/ethiopic/utils/index.ts index 1dc38356a..e9ede1a3d 100644 --- a/src/ethiopic/utils/index.ts +++ b/src/ethiopic/utils/index.ts @@ -2,3 +2,5 @@ export * from "./EthiopicDate.js"; export * from "./isEthiopicLeapYear.js"; export * from "./toEthiopicDate.js"; export * from "./toGregorianDate.js"; +export * from "./geezConverter.js"; +export * from "./daysInMonth.js"; diff --git a/src/ethiopic/utils/isEthiopicDateValid.ts b/src/ethiopic/utils/isEthiopicDateValid.ts new file mode 100644 index 000000000..7fcf80616 --- /dev/null +++ b/src/ethiopic/utils/isEthiopicDateValid.ts @@ -0,0 +1,10 @@ +import { EthiopicDate } from "./EthiopicDate.js"; +import { daysInMonth } from "./daysInMonth.js"; + +export function isEthiopicDateValid(date: EthiopicDate): boolean { + if (date.month < 1) return false; + if (date.day < 1) return false; + if (date.month > 13) return false; + if (date.day > daysInMonth(date.month, date.year)) return false; + return true; +} diff --git a/src/ethiopic/utils/isEthiopicLeapYear.test.ts b/src/ethiopic/utils/isEthiopicLeapYear.test.ts index ece0ec678..67dcf014a 100644 --- a/src/ethiopic/utils/isEthiopicLeapYear.test.ts +++ b/src/ethiopic/utils/isEthiopicLeapYear.test.ts @@ -1,4 +1,18 @@ +import { isEthiopicLeapYear } from "./isEthiopicLeapYear"; + describe("isEthiopicLeapYear", () => { - test.todo("should return true for a leap year"); - test.todo("should return false for a non-leap year"); + test("should return true for leap years", () => { + // In Ethiopic calendar, years that give remainder 3 when divided by 4 are leap years + expect(isEthiopicLeapYear(2003)).toBe(true); + expect(isEthiopicLeapYear(2007)).toBe(true); + expect(isEthiopicLeapYear(2011)).toBe(true); + expect(isEthiopicLeapYear(2015)).toBe(true); + }); + + test("should return false for non-leap years", () => { + expect(isEthiopicLeapYear(2001)).toBe(false); + expect(isEthiopicLeapYear(2002)).toBe(false); + expect(isEthiopicLeapYear(2004)).toBe(false); + expect(isEthiopicLeapYear(2005)).toBe(false); + }); }); diff --git a/src/ethiopic/utils/toEthiopicDate.test.ts b/src/ethiopic/utils/toEthiopicDate.test.ts index 3d30b8285..b195b5fc2 100644 --- a/src/ethiopic/utils/toEthiopicDate.test.ts +++ b/src/ethiopic/utils/toEthiopicDate.test.ts @@ -1,3 +1,62 @@ -test.todo("should convert a Gregorian date to an Ethiopic date correctly"); -test.todo("should handle leap years correctly"); -test.todo("should handle dates before the Ethiopic epoch correctly"); +import { toEthiopicDate } from "./toEthiopicDate"; + +describe("toEthiopicDate", () => { + test("should convert a Gregorian date to an Ethiopic date correctly", () => { + const testCases = [ + { + gregorian: new Date(2024, 2, 9), // March 9, 2024 + ethiopic: { year: 2016, month: 6, day: 30 } + }, + { + gregorian: new Date(2024, 0, 1), // January 1, 2024 + ethiopic: { year: 2016, month: 4, day: 22 } + }, + { + gregorian: new Date(2023, 8, 11), // September 11, 2023 + ethiopic: { year: 2015, month: 13, day: 6 } // Ethiopian New Year + }, + { + gregorian: new Date(2024, 8, 10), // September 10, 2024 + ethiopic: { year: 2016, month: 13, day: 5 } // Ethiopian New Year + } + ]; + + testCases.forEach(({ gregorian, ethiopic }) => { + expect(toEthiopicDate(gregorian)).toEqual(ethiopic); + }); + }); + + test("should handle leap years correctly", () => { + const testCases = [ + { + gregorian: new Date(2024, 1, 29), // February 29, 2024 (Gregorian leap year) + ethiopic: { year: 2016, month: 6, day: 21 } + }, + { + gregorian: new Date(2023, 8, 11), // August 29, 2024 + ethiopic: { year: 2015, month: 13, day: 6 } // Pagume (Ethiopian 13th month) + } + ]; + + testCases.forEach(({ gregorian, ethiopic }) => { + expect(toEthiopicDate(gregorian)).toEqual(ethiopic); + }); + }); + + test("should handle dates before the Ethiopic epoch correctly", () => { + const testCases = [ + { + gregorian: new Date(1900, 0, 1), // January 1, 1900 + ethiopic: { year: 1892, month: 4, day: 23 } + }, + { + gregorian: new Date(1800, 5, 15), // June 15, 1800 + ethiopic: { year: 1792, month: 10, day: 9 } + } + ]; + + testCases.forEach(({ gregorian, ethiopic }) => { + expect(toEthiopicDate(gregorian)).toEqual(ethiopic); + }); + }); +}); diff --git a/src/ethiopic/utils/toEthiopicDate.ts b/src/ethiopic/utils/toEthiopicDate.ts index a100b0d87..1bae6a05d 100644 --- a/src/ethiopic/utils/toEthiopicDate.ts +++ b/src/ethiopic/utils/toEthiopicDate.ts @@ -1,5 +1,47 @@ -import type { EthiopicDate } from "./EthiopicDate.js"; -import { ETHIOPIC_EPOCH_OFFSET } from "./consts.js"; +import { differenceInCalendarDays } from "date-fns"; + +import { EthiopicDate } from "./EthiopicDate.js"; + +/** + * Calculates the number of days between January 1, 0001 and the given date. + * + * @param date - A JavaScript Date object to calculate days from + * @returns The number of days since January 1, 0001. Returns 0 if the input is + * not a valid Date. + */ +export function getDayNoGregorian(date: Date): number { + if (!(date instanceof Date)) { + return 0; + } + // Create the start date as January 1, 0001. + // Using an ISO string avoids issues with the Date constructor and two-digit years. + const adStart = new Date("0001-01-01"); + + // Calculate the number of days between the two dates, then add 1. + const dayNumber = differenceInCalendarDays(date, adStart) + 1; + + return dayNumber; +} + +function createEthiopicDate(dn: number): EthiopicDate { + const num = Math.floor(dn / 1461); + const num2 = dn % 1461; + const num3 = Math.floor(num2 / 365); + const num4 = num2 % 365; + if (num2 !== 1460) { + return { + year: num * 4 + num3, + month: Math.floor(num4 / 30) + 1, + day: (num4 % 30) + 1 + }; + } else { + return { + year: num * 4 + num3 - 1, + month: 13, + day: 6 + }; + } +} /** * Converts a Gregorian date to an Ethiopic date. @@ -9,13 +51,5 @@ import { ETHIOPIC_EPOCH_OFFSET } from "./consts.js"; * @returns An EthiopicDate object. */ export function toEthiopicDate(gregorianDate: Date): EthiopicDate { - const julianDay = Math.floor(gregorianDate.getTime() / 86400000 + 2440587.5); - const ethiopicDayNumber = julianDay + ETHIOPIC_EPOCH_OFFSET; - - const year = Math.floor((4 * ethiopicDayNumber + 1463) / 1461); - const dayOfYear = ethiopicDayNumber - (365 * year + Math.floor(year / 4)); - const month = Math.floor(dayOfYear / 30) + 1; - const day = (dayOfYear % 30) + 1; - - return { year, month, day }; + return createEthiopicDate(getDayNoGregorian(gregorianDate) - 2431); } diff --git a/src/ethiopic/utils/toGregorianDate.test.ts b/src/ethiopic/utils/toGregorianDate.test.ts index 2937f87bf..e39211c54 100644 --- a/src/ethiopic/utils/toGregorianDate.test.ts +++ b/src/ethiopic/utils/toGregorianDate.test.ts @@ -1,8 +1,43 @@ +import { EthiopicDate } from "./EthiopicDate"; +import { toGregorianDate } from "./toGregorianDate"; + describe("toGregorianDate", () => { - test.todo("convert an Ethiopic date to the correct Gregorian date"); - test.todo("handle leap years correctly"); - test.todo("handle the last day of the year correctly"); - test.todo("handle the first day of the year correctly"); - test.todo("handle invalid dates gracefully"); - test.todo("handle edge cases for month and day values"); + test("convert an Ethiopic date to the correct Gregorian date", () => { + const ethiopicDate: EthiopicDate = { year: 2015, month: 1, day: 1 }; + const gregorianDate = toGregorianDate(ethiopicDate); + expect(gregorianDate).toEqual(new Date(2022, 8, 11)); // September 11, 2022 + }); + + test("handle leap years correctly", () => { + const ethiopicDate: EthiopicDate = { year: 2015, month: 13, day: 6 }; + const gregorianDate = toGregorianDate(ethiopicDate); + expect(gregorianDate).toEqual(new Date(2023, 8, 11)); // September 11, 2023 + }); + + test("handle the last day of the year correctly", () => { + const ethiopicDate: EthiopicDate = { year: 2014, month: 13, day: 5 }; + const gregorianDate = toGregorianDate(ethiopicDate); + expect(gregorianDate).toEqual(new Date(2022, 8, 10)); // September 10, 2022 + }); + + test("handle the first day of the year correctly", () => { + const ethiopicDate: EthiopicDate = { year: 2014, month: 1, day: 1 }; + const gregorianDate = toGregorianDate(ethiopicDate); + expect(gregorianDate).toEqual(new Date(2021, 8, 11)); // September 11, 2021 + }); + + test("handle invalid dates gracefully", () => { + const invalidDate: EthiopicDate = { year: 2015, month: 14, day: 1 }; + expect(() => toGregorianDate(invalidDate)).toThrow(); + }); + + test("handle edge cases for month and day values", () => { + // Test month 13 with regular year (5 days) + const pagume: EthiopicDate = { year: 2014, month: 13, day: 1 }; + expect(toGregorianDate(pagume)).toEqual(new Date(2022, 8, 6)); // September 6, 2022 + + // Test last day of a regular month + const lastDayOfMonth: EthiopicDate = { year: 2015, month: 1, day: 30 }; + expect(toGregorianDate(lastDayOfMonth)).toEqual(new Date(2022, 9, 10)); // October 10, 2022 + }); }); diff --git a/src/ethiopic/utils/toGregorianDate.ts b/src/ethiopic/utils/toGregorianDate.ts index 5dd0ec151..64118de1b 100644 --- a/src/ethiopic/utils/toGregorianDate.ts +++ b/src/ethiopic/utils/toGregorianDate.ts @@ -1,6 +1,61 @@ +import { getDaysInMonth } from "date-fns"; + import type { EthiopicDate } from "./EthiopicDate.js"; -import { ETHIOPIC_EPOCH_OFFSET } from "./consts.js"; +import { isEthiopicDateValid } from "./isEthiopicDateValid.js"; + +export function getDayNoEthiopian(etDate: EthiopicDate): number { + const num = Math.floor(etDate.year / 4); + const num2 = etDate.year % 4; + return num * 1461 + num2 * 365 + (etDate.month - 1) * 30 + etDate.day - 1; +} + +function gregorianDateFromDayNo(dayNum: number): Date { + let year = 1, + month = 1, + day; + + const num400 = Math.floor(dayNum / 146097); // number of full 400-year periods + dayNum %= 146097; + if (dayNum === 0) { + return new Date(400 * num400, 12 - 1, 31); + } + + const num100 = Math.min(Math.floor(dayNum / 36524), 3); // number of full 100-year periods, but not more than 3 + dayNum -= num100 * 36524; + if (dayNum === 0) { + return new Date(400 * num400 + 100 * num100, 12 - 1, 31); + } + + const num4 = Math.floor(dayNum / 1461); // number of full 4-year periods + dayNum %= 1461; + if (dayNum === 0) { + return new Date(400 * num400 + 100 * num100 + 4 * num4, 12 - 1, 31); + } + const num1 = Math.min(Math.floor(dayNum / 365), 3); // number of full years, but not more than 3 + dayNum -= num1 * 365; + if (dayNum === 0) { + return new Date(400 * num400 + 100 * num100 + 4 * num4 + num1, 12 - 1, 31); + } + + year += 400 * num400 + 100 * num100 + 4 * num4 + num1; + + while (dayNum > 0) { + const tempDate = new Date(year, month - 1); + const daysInMonth = getDaysInMonth(tempDate); + + if (dayNum <= daysInMonth) { + day = dayNum; + break; + } + + dayNum -= daysInMonth; + month++; + } + + // Remember in JavaScript Date object, months are 0-based. + return new Date(year, month - 1, day); +} /** * Converts an Ethiopic date to a Gregorian date. * @@ -8,12 +63,9 @@ import { ETHIOPIC_EPOCH_OFFSET } from "./consts.js"; * @returns A JavaScript Date object representing the Gregorian date. */ export function toGregorianDate(ethiopicDate: EthiopicDate): Date { - const yearStartJulianDay = - 365 * ethiopicDate.year + - Math.floor(ethiopicDate.year / 4) - - ETHIOPIC_EPOCH_OFFSET; - const julianDay = - yearStartJulianDay + (ethiopicDate.month - 1) * 30 + (ethiopicDate.day - 1); - const gregorianTime = (julianDay - 2440587.5) * 86400000; - return new Date(gregorianTime); + if (!isEthiopicDateValid(ethiopicDate)) { + throw new Error("Invalid Ethiopic date"); + } + + return gregorianDateFromDayNo(getDayNoEthiopian(ethiopicDate) + 2431); } diff --git a/src/types/shared.ts b/src/types/shared.ts index 27cb3a8a4..49650554b 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -416,7 +416,7 @@ export type MoveFocusBy = * - `arab`: Arabic-Indic * - `arabext`: Eastern Arabic-Indic (Persian) * - `deva`: Devanagari - * - `ethio`: Ethiopic + * - `geez`: Ethiopic * - `beng`: Bengali * - `guru`: Gurmukhi * - `gujr`: Gujarati @@ -433,7 +433,7 @@ export type Numerals = | "arab" | "arabext" | "deva" - | "ethio" + | "geez" | "beng" | "guru" | "gujr" diff --git a/website/docs/docs/localization.mdx b/website/docs/docs/localization.mdx index 4ca7d51fa..4427ddf72 100644 --- a/website/docs/docs/localization.mdx +++ b/website/docs/docs/localization.mdx @@ -127,6 +127,40 @@ export function PersianCalendar() { +## Ethiopic Calendar + +DayPicker supports the [Ethiopic calendar](https://en.wikipedia.org/wiki/Ethiopian_calendar), which is the principal calendar used in Ethiopia and Eritrea. + +- To use the Ethiopic calendar, import `DayPicker` from `react-day-picker/ethiopic`: + +```diff +- import { DayPicker } from 'react-day-picker'; ++ import { DayPicker } from 'react-day-picker/ethiopic'; +``` + + + + + +### Numerals (Geez) + +By default, the Ethiopic calendar uses Latin numerals (Western Arabic numbers). + +- To use the Geez numeral system, pass the `numerals` prop: + +```tsx +import { DayPicker } from "react-day-picker/ethiopic"; + +export function EthiopicGeez() { + return ; +} +``` + + + + + + ## Broadcast Calendar | Prop Name | Type | Description |