├── jest.config.js ├── assets └── img │ └── kenatBanner.png ├── types ├── render │ └── printMonthCalendarGrid.d.ts ├── geezConverter.d.ts ├── bahireHasab.d.ts ├── index.d.ts ├── holidays.d.ts ├── MonthGrid.d.ts ├── fasting.d.ts ├── errors │ └── errorHandler.d.ts ├── conversions.d.ts ├── formatting.d.ts ├── dayArithmetic.d.ts ├── Time.d.ts └── utils.d.ts ├── jsdoc.json ├── .gitignore ├── tsconfig.json ├── .github ├── workflows │ └── test.yml └── FUNDING.yml ├── src ├── index.js ├── render │ └── printMonthCalendarGrid.js ├── formatting.js ├── geezConverter.js ├── errors │ └── errorHandler.js ├── bahireHasab.js ├── fasting.js ├── utils.js ├── conversions.js ├── dayArithmetic.js ├── MonthGrid.js └── Time.js ├── LICENSE ├── tests ├── print.test.js ├── ethiopianNumberConverter.test.js ├── bahireHasab.test.js ├── geezConverter.test.js ├── Kenat.test.js ├── methodChaining.test.js ├── holidays.test.js ├── monthgrid.test.js ├── utils.test.js ├── conversions.test.js ├── fasting.test.js ├── Time.test.js └── dayArtimetic.test.js ├── package.json ├── RELEASE_NOTES_3.2.0.md ├── Features.md └── examples └── vanilla ├── month └── index.html ├── ethiopian-clock.html └── fullYearCalendar.html /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: "node", 3 | transform: {}, 4 | }; 5 | -------------------------------------------------------------------------------- /assets/img/kenatBanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MelakuDemeke/kenat/HEAD/assets/img/kenatBanner.png -------------------------------------------------------------------------------- /types/render/printMonthCalendarGrid.d.ts: -------------------------------------------------------------------------------- 1 | export function printMonthCalendarGrid(ethiopianYear: any, ethiopianMonth: any, calendarData: any, useGeez?: boolean): void; 2 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": ["src", "README.md"], 4 | "includePattern": ".js$", 5 | "excludePattern": "(node_modules|docs)" 6 | }, 7 | "opts": { 8 | "destination": "./docs", 9 | "recurse": true, 10 | "readme": "README.md" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node modules 2 | node_modules/ 3 | 4 | # Logs 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # OS files 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Environment variables 14 | .env 15 | 16 | # Jest coverage output 17 | coverage/ 18 | 19 | # Build output (if any) 20 | dist/ 21 | build/ 22 | /docs 23 | 24 | # VS Code settings (optional, if you use VS Code) 25 | .vscode/ 26 | Features.md\ 27 | 28 | demo.js 29 | all_js_code.txt 30 | bundle_js_to_txt.py 31 | /docs -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": false, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "declarationDir": "./types", 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "target": "ES2020", 11 | "module": "ESNext", 12 | "lib": ["es2022", "dom", "es2020.intl"], 13 | "skipLibCheck": true, 14 | "noEmitOnError": true 15 | }, 16 | "include": [ 17 | "src/**/*.js" 18 | ] 19 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Main Branch Test Workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '18' # or any version you use 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Run tests 25 | run: npm test 26 | -------------------------------------------------------------------------------- /types/geezConverter.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a natural number to Ethiopic numeral string. 3 | * 4 | * @param {number|string} input - The number to convert (positive integer only). 5 | * @returns {string} Ethiopic numeral string. 6 | * @throws {GeezConverterError} If input is not a valid positive integer. 7 | */ 8 | export function toGeez(input: number | string): string; 9 | /** 10 | * Converts a Ge'ez numeral string to its Arabic numeral equivalent. 11 | * 12 | * @param {string} geezStr - The Ge'ez numeral string to convert. 13 | * @returns {number} The Arabic numeral representation of the input string. 14 | * @throws {GeezConverterError} If the input is not a valid Ge'ez numeral string. 15 | */ 16 | export function toArabic(geezStr: string): number; 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | # polar: # Replace with a single Polar username 13 | # buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | # thanks_dev: # Replace with a single thanks.dev username 15 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | github: [MelakuDemeke] 17 | -------------------------------------------------------------------------------- /types/bahireHasab.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates all Bahire Hasab values for a given Ethiopian year, including all movable feasts. 3 | * 4 | * @param {number} ethiopianYear - The Ethiopian year to calculate for. 5 | * @param {Object} [options={}] - Options for language. 6 | * @param {string} [options.lang='amharic'] - The language for names. 7 | * @returns {Object} An object containing all the calculated Bahire Hasab values. 8 | */ 9 | export function getBahireHasab(ethiopianYear: number, options?: { 10 | lang?: string; 11 | }): any; 12 | /** 13 | * Calculates the date of a movable holiday for a given year. 14 | * This is now a pure date calculator that returns a simple date object, 15 | * ensuring backward compatibility with existing tests. 16 | * 17 | * @param {'ABIY_TSOME'|'TINSAYE'|'ERGET'|...} holidayKey - The key of the holiday from movableHolidayTewsak. 18 | * @param {number} ethiopianYear - The Ethiopian year. 19 | * @returns {Object} An Ethiopian date object { year, month, day }. 20 | */ 21 | export function getMovableHoliday(holidayKey: any, ethiopianYear: number): any; 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Kenat } from './Kenat.js'; 2 | import { diffBreakdown } from './dayArithmetic.js'; 3 | import { MonthGrid } from './MonthGrid.js'; 4 | import { toEC, toGC } from './conversions.js'; 5 | import { toArabic, toGeez } from './geezConverter.js'; 6 | import { getHolidaysInMonth, getHoliday, getHolidaysForYear } from './holidays.js'; 7 | import { Time } from './Time.js'; 8 | import { HolidayTags, HolidayNames } from './constants.js'; 9 | import { getBahireHasab } from './bahireHasab.js'; 10 | import { monthNames } from './constants.js'; 11 | import { getFastingPeriod, getFastingInfo, getFastingDays } from './fasting.js'; 12 | 13 | // Default export is the Kenat class directly 14 | export default Kenat; 15 | 16 | // Named exports for the conversion functions 17 | export { 18 | toEC as toEC, 19 | toGC, 20 | toArabic, 21 | toGeez, 22 | getHolidaysInMonth, 23 | getHolidaysForYear, 24 | getBahireHasab, 25 | getFastingPeriod, 26 | getFastingInfo, 27 | getFastingDays, 28 | MonthGrid, 29 | Time, 30 | getHoliday, 31 | HolidayTags, 32 | HolidayNames, 33 | monthNames, 34 | diffBreakdown, 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Melaku Demeke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export default Kenat; 2 | import { Kenat } from './Kenat.js'; 3 | import { toEC } from './conversions.js'; 4 | import { toGC } from './conversions.js'; 5 | import { toArabic } from './geezConverter.js'; 6 | import { toGeez } from './geezConverter.js'; 7 | import { getHolidaysInMonth } from './holidays.js'; 8 | import { getHolidaysForYear } from './holidays.js'; 9 | import { getBahireHasab } from './bahireHasab.js'; 10 | import { getFastingPeriod } from './fasting.js'; 11 | import { getFastingInfo } from './fasting.js'; 12 | import { getFastingDays } from './fasting.js'; 13 | import { MonthGrid } from './MonthGrid.js'; 14 | import { Time } from './Time.js'; 15 | import { getHoliday } from './holidays.js'; 16 | import { HolidayTags } from './constants.js'; 17 | import { HolidayNames } from './constants.js'; 18 | import { monthNames } from './constants.js'; 19 | import { diffBreakdown } from './dayArithmetic.js'; 20 | export { toEC, toGC, toArabic, toGeez, getHolidaysInMonth, getHolidaysForYear, getBahireHasab, getFastingPeriod, getFastingInfo, getFastingDays, MonthGrid, Time, getHoliday, HolidayTags, HolidayNames, monthNames, diffBreakdown }; 21 | -------------------------------------------------------------------------------- /types/holidays.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Finds the start and end dates of a specific Hijri month that falls within an Ethiopian year. 3 | * @param {number} ethiopianYear - The Ethiopian year. 4 | * @param {number} hijriMonth - The Hijri month to find (e.g., 9 for Ramadan). 5 | * @returns {Array<{start: Kenat, end: Kenat}>} An array of start/end date ranges. 6 | */ 7 | export function findHijriMonthRanges(ethiopianYear: number, hijriMonth: number): Array<{ 8 | start: Kenat; 9 | end: Kenat; 10 | }>; 11 | export function getHoliday(holidayKey: any, ethYear: any, options?: {}): { 12 | key: any; 13 | tags: any; 14 | movable: boolean; 15 | name: any; 16 | description: any; 17 | ethiopian: { 18 | year: any; 19 | month: any; 20 | day: any; 21 | }; 22 | gregorian?: undefined; 23 | } | { 24 | key: any; 25 | tags: any; 26 | movable: boolean; 27 | name: any; 28 | description: any; 29 | ethiopian: any; 30 | gregorian: any; 31 | }; 32 | export function getHolidaysInMonth(ethYear: any, ethMonth: any, options?: {}): any[]; 33 | export function getHolidaysForYear(ethYear: any, options?: {}): any[]; 34 | -------------------------------------------------------------------------------- /types/MonthGrid.d.ts: -------------------------------------------------------------------------------- 1 | export class MonthGrid { 2 | static create(config?: {}): { 3 | headers: any; 4 | days: any[]; 5 | year: any; 6 | month: any; 7 | monthName: any; 8 | up: () => /*elided*/ any; 9 | down: () => /*elided*/ any; 10 | }; 11 | constructor(config?: {}); 12 | year: any; 13 | month: any; 14 | weekStart: any; 15 | useGeez: any; 16 | weekdayLang: any; 17 | holidayFilter: any; 18 | mode: any; 19 | showAllSaints: any; 20 | _validateConfig(config: any): void; 21 | generate(): { 22 | headers: any; 23 | days: any[]; 24 | year: any; 25 | month: any; 26 | monthName: any; 27 | up: () => { 28 | headers: any; 29 | days: any[]; 30 | year: any; 31 | month: any; 32 | monthName: any; 33 | up: /*elided*/ any; 34 | down: () => /*elided*/ any; 35 | }; 36 | down: () => { 37 | headers: any; 38 | days: any[]; 39 | year: any; 40 | month: any; 41 | monthName: any; 42 | up: () => /*elided*/ any; 43 | down: /*elided*/ any; 44 | }; 45 | }; 46 | _getRawDays(): any[]; 47 | _getFilteredHolidays(): any[]; 48 | _getSaintsMap(): {}; 49 | _mergeDays(rawDays: any, holidaysList: any, saintsMap: any): any[]; 50 | _getWeekdayHeaders(): any; 51 | _getLocalizedMonthName(): any; 52 | _getLocalizedYear(): any; 53 | up(): this; 54 | down(): this; 55 | } 56 | -------------------------------------------------------------------------------- /tests/print.test.js: -------------------------------------------------------------------------------- 1 | import Kenat from '../src/index.js'; 2 | 3 | describe('Kenat - getMonthCalendar', () => { 4 | test('should return 30 days for a standard Ethiopian month', () => { 5 | const k = new Kenat('2015/1/1'); 6 | const calendar = k.getMonthCalendar(2015, 1); 7 | expect(calendar.length).toBe(30); 8 | }); 9 | 10 | test('should return 6 days for Pagumē in a leap year (year % 4 === 3)', () => { 11 | const k = new Kenat('2011/13/1'); // 2011 is a leap year in Ethiopian calendar 12 | const calendar = k.getMonthCalendar(2011, 13); 13 | expect(calendar.length).toBe(6); 14 | }); 15 | 16 | test('should return 5 days for Pagumē in a non-leap year', () => { 17 | const k = new Kenat('2012/13/1'); 18 | const calendar = k.getMonthCalendar(2012, 13); 19 | expect(calendar.length).toBe(5); 20 | }); 21 | 22 | test('each day should include properly formatted Ethiopian and Gregorian fields', () => { 23 | const k = new Kenat('2015/2/1'); 24 | const calendar = k.getMonthCalendar(2015, 2, false); 25 | const day = calendar[0]; 26 | 27 | expect(day.ethiopian).toHaveProperty('display'); 28 | expect(day.gregorian).toHaveProperty('display'); 29 | expect(typeof day.ethiopian.display).toBe('string'); 30 | expect(typeof day.gregorian.display).toBe('string'); 31 | }); 32 | 33 | test('should correctly format Geez numerals when useGeez = true', () => { 34 | const k = new Kenat('2015/1/1'); 35 | const calendar = k.getMonthCalendar(2015, 1, true); 36 | const day = calendar[0]; 37 | expect(day.ethiopian.display).toMatch(/[፩-፻]/); // Regex to match at least one Geez numeral 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kenat", 3 | "version": "3.2.0", 4 | "description": "A JavaScript library for the Ethiopian calendar with date and time support.", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 9 | "docs": "jsdoc -c jsdoc.json", 10 | "release:patch": "npm version patch && git push && git push --tags && npm publish", 11 | "release:minor": "npm version minor && git push && git push --tags && npm publish", 12 | "release:major": "npm version major && git push && git push --tags && npm publish", 13 | "prepack": "tsc -p tsconfig.json" 14 | }, 15 | "keywords": [ 16 | "ethiopian", 17 | "calendar", 18 | "ethiopian calendar", 19 | "date", 20 | "time", 21 | "kenat", 22 | "habesha", 23 | "zemen", 24 | "ethiopian date", 25 | "geez calendar", 26 | "ethiopian calendar library", 27 | "amharic calendar", 28 | "date converter", 29 | "calendar converter", 30 | "ethiopian holidays", 31 | "react calendar", 32 | "js calendar", 33 | "javascript calendar", 34 | "calendar component", 35 | "Bahire Hasab", 36 | "Abushakir" 37 | ], 38 | "author": "Melaku Demeke", 39 | "license": "MIT", 40 | "types": "./types/index.d.ts", 41 | "exports": { 42 | ".": { 43 | "types": "./types/index.d.ts", 44 | "import": "./src/index.js" 45 | } 46 | }, 47 | "repository": { 48 | "type": "git", 49 | "url": "git+https://github.com/MelakuDemeke/kenat.git" 50 | }, 51 | "bugs": { 52 | "url": "https://github.com/MelakuDemeke/kenat/issues" 53 | }, 54 | "homepage": "https://www.kenat.systems/", 55 | "devDependencies": { 56 | "jest": "^29.7.0", 57 | "jsdoc": "^4.0.4", 58 | "typescript": "^5.9.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /types/fasting.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the start and end dates of a specific fasting period for a given year. 3 | * @param {'ABIY_TSOME' | 'TSOME_HAWARYAT' | 'TSOME_NEBIYAT' | 'NINEVEH' | 'RAMADAN'} fastKey - The key for the fast. 4 | * @param {number} ethiopianYear - The Ethiopian year. 5 | * @returns {{start: object, end: object}|null} An object with start and end PLAIN date objects. 6 | */ 7 | export function getFastingPeriod(fastKey: "ABIY_TSOME" | "TSOME_HAWARYAT" | "TSOME_NEBIYAT" | "NINEVEH" | "RAMADAN", ethiopianYear: number): { 8 | start: object; 9 | end: object; 10 | } | null; 11 | /** 12 | * Returns fasting information (names, descriptions, period) for a given fast and year. 13 | * @param {'ABIY_TSOME'|'TSOME_HAWARYAT'|'TSOME_NEBIYAT'|'NINEVEH'|'RAMADAN'} fastKey 14 | * @param {number} ethiopianYear 15 | * @param {{lang?: 'amharic'|'english'}} options 16 | * @returns {{ key: string, name: string, description: string, period: { start: object, end: object } } | null} 17 | */ 18 | export function getFastingInfo(fastKey: "ABIY_TSOME" | "TSOME_HAWARYAT" | "TSOME_NEBIYAT" | "NINEVEH" | "RAMADAN", ethiopianYear: number, options?: { 19 | lang?: "amharic" | "english"; 20 | }): { 21 | key: string; 22 | name: string; 23 | description: string; 24 | period: { 25 | start: object; 26 | end: object; 27 | }; 28 | } | null; 29 | /** 30 | * Return an array of day numbers in the given Ethiopian month that belong to a fasting period. 31 | * For TSOME_DIHENET, it returns all Wednesdays and Fridays excluding the 50-day period after Easter (through Pentecost). 32 | * For fixed/range fasts, it returns the days intersecting the fast period. 33 | * 34 | * @param {string} fastKey - One of FastingKeys 35 | * @param {number} year - Ethiopian year 36 | * @param {number} month - Ethiopian month (1-13) 37 | * @returns {number[]} 38 | */ 39 | export function getFastingDays(fastKey: string, year: number, month: number): number[]; 40 | -------------------------------------------------------------------------------- /src/render/printMonthCalendarGrid.js: -------------------------------------------------------------------------------- 1 | import { monthNames } from '../constants.js'; 2 | import { toGeez } from '../geezConverter.js'; 3 | import { validateNumericInputs } from '../utils.js'; 4 | import { InvalidInputTypeError } from '../errors/errorHandler.js'; 5 | 6 | export function printMonthCalendarGrid(ethiopianYear, ethiopianMonth, calendarData, useGeez = false) { 7 | validateNumericInputs('printMonthCalendarGrid', { ethiopianYear, ethiopianMonth }); 8 | if (ethiopianMonth < 1 || ethiopianMonth > 13) { 9 | throw new InvalidInputTypeError('printMonthCalendarGrid', 'ethiopianMonth', 'number between 1 and 13', ethiopianMonth); 10 | } 11 | if (!Array.isArray(calendarData) || calendarData.length === 0) { 12 | // This function would crash if calendarData is empty or not an array, so we check it. 13 | console.error("Calendar data is empty or invalid. Cannot print grid."); 14 | return; 15 | } 16 | 17 | const daysOfWeek = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; 18 | console.log(`\n ${monthNames.amharic[ethiopianMonth - 1]} ${useGeez ? toGeez(ethiopianYear) : ethiopianYear}`); 19 | console.log(daysOfWeek.join(' ')); 20 | 21 | const firstDayGregorian = calendarData[0].gregorian; 22 | const jsDate = new Date(firstDayGregorian.year, firstDayGregorian.month - 1, firstDayGregorian.day); 23 | const weekDay = (jsDate.getDay() + 6) % 7; // Monday is 0 24 | 25 | let row = Array(weekDay).fill(' '); 26 | 27 | for (const day of calendarData) { 28 | const ethDay = useGeez ? toGeez(day.ethiopian.day).padStart(2, '፩') : String(day.ethiopian.day).padStart(2, ' '); 29 | const gregDay = String(day.gregorian.day).padStart(2, ' '); 30 | row.push(`${ethDay}/${gregDay}`); 31 | 32 | if (row.length === 7) { 33 | console.log(row.join(' ')); 34 | row = []; 35 | } 36 | } 37 | 38 | if (row.length > 0) { 39 | console.log(row.join(' ').padEnd(27, ' ')); // Pad the last row 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /types/errors/errorHandler.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for all custom errors in the Kenat library. 3 | */ 4 | export class KenatError extends Error { 5 | constructor(message: any); 6 | /** 7 | * Provides a serializable representation of the error. 8 | * @returns {Object} A plain object with error details. 9 | */ 10 | toJSON(): any; 11 | } 12 | /** 13 | * Thrown when an Ethiopian date is numerically invalid (e.g., month 14). 14 | */ 15 | export class InvalidEthiopianDateError extends KenatError { 16 | constructor(year: any, month: any, day: any); 17 | date: { 18 | year: any; 19 | month: any; 20 | day: any; 21 | }; 22 | } 23 | /** 24 | * Thrown when a Gregorian date is numerically invalid. 25 | */ 26 | export class InvalidGregorianDateError extends KenatError { 27 | constructor(year: any, month: any, day: any); 28 | date: { 29 | year: any; 30 | month: any; 31 | day: any; 32 | }; 33 | } 34 | /** 35 | * Thrown when a date string provided to the constructor has an invalid format. 36 | */ 37 | export class InvalidDateFormatError extends KenatError { 38 | inputString: any; 39 | } 40 | /** 41 | * Thrown when the Kenat constructor receives an input type it cannot handle. 42 | */ 43 | export class UnrecognizedInputError extends KenatError { 44 | input: any; 45 | } 46 | /** 47 | * Thrown for errors occurring during Ge'ez numeral conversion. 48 | */ 49 | export class GeezConverterError extends KenatError { 50 | } 51 | /** 52 | * Thrown when a function receives an argument of an incorrect type. 53 | */ 54 | export class InvalidInputTypeError extends KenatError { 55 | constructor(functionName: any, parameterName: any, expectedType: any, receivedValue: any); 56 | functionName: any; 57 | parameterName: any; 58 | expectedType: any; 59 | receivedValue: any; 60 | } 61 | /** 62 | * Thrown for errors related to invalid time components. 63 | */ 64 | export class InvalidTimeError extends KenatError { 65 | } 66 | /** 67 | * Thrown for invalid configuration options passed to MonthGrid. 68 | */ 69 | export class InvalidGridConfigError extends KenatError { 70 | } 71 | /** 72 | * Thrown when an unknown holiday key is used. 73 | */ 74 | export class UnknownHolidayError extends KenatError { 75 | holidayKey: any; 76 | } 77 | -------------------------------------------------------------------------------- /tests/ethiopianNumberConverter.test.js: -------------------------------------------------------------------------------- 1 | import { toGeez, toArabic } from '../src/geezConverter.js'; 2 | import { GeezConverterError } from '../src/errors/errorHandler.js'; 3 | 4 | describe('toGeez', () => { 5 | test('converts single digits correctly', () => { 6 | expect(toGeez(0)).toBe('0'); 7 | expect(toGeez(1)).toBe('፩'); 8 | expect(toGeez(5)).toBe('፭'); 9 | expect(toGeez(9)).toBe('፱'); 10 | }); 11 | 12 | test('converts tens correctly', () => { 13 | expect(toGeez(10)).toBe('፲'); 14 | expect(toGeez(30)).toBe('፴'); 15 | expect(toGeez(99)).toBe('፺፱'); 16 | }); 17 | 18 | test('converts hundreds correctly', () => { 19 | expect(toGeez(100)).toBe('፻'); 20 | expect(toGeez(123)).toBe('፻፳፫'); 21 | expect(toGeez(999)).toBe('፱፻፺፱'); 22 | 23 | }); 24 | 25 | test('converts thousands and ten-thousands correctly', () => { 26 | expect(toGeez(10000)).toBe('፼'); 27 | expect(toGeez(12345)).toBe('፼፳፫፻፵፭'); 28 | expect(toGeez(99999)).toBe('፱፼፺፱፻፺፱'); 29 | }); 30 | 31 | test('throws error for invalid inputs', () => { 32 | expect(() => toGeez(-1)).toThrow(); 33 | expect(() => toGeez('abc')).toThrow(); 34 | expect(() => toGeez(null)).toThrow(); 35 | }); 36 | }); 37 | 38 | describe('toArabic (reverse of toGeez)', () => { 39 | test('reverses single digits correctly', () => { 40 | expect(toArabic('፩')).toBe(1); 41 | expect(toArabic('፭')).toBe(5); 42 | expect(toArabic('፱')).toBe(9); 43 | }); 44 | 45 | test('reverses tens correctly', () => { 46 | expect(toArabic('፲')).toBe(10); 47 | expect(toArabic('፴')).toBe(30); 48 | expect(toArabic('፺፱')).toBe(99); 49 | }); 50 | 51 | test('reverses hundreds correctly', () => { 52 | expect(toArabic('፻')).toBe(100); 53 | expect(toArabic('፻፳፫')).toBe(123); 54 | expect(toArabic('፱፻፺፱')).toBe(999); 55 | }); 56 | 57 | test('reverses thousands and ten-thousands correctly', () => { 58 | expect(toArabic('፼')).toBe(10000); 59 | expect(toArabic('፼፳፫፻፵፭')).toBe(12345); 60 | expect(toArabic('፱፼፺፱፻፺፱')).toBe(99999); 61 | }); 62 | 63 | test('throws error for invalid geez input', () => { 64 | expect(() => toArabic('xyz')).toThrow(GeezConverterError); 65 | expect(toArabic('')).toBe(0); 66 | expect(() => toArabic('፻፻፻x')).toThrow(GeezConverterError); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /types/conversions.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts an Ethiopian date to its corresponding Gregorian date. 3 | * 4 | * @param {number} ethYear - The Ethiopian year. 5 | * @param {number} ethMonth - The Ethiopian month (1-13). 6 | * @param {number} ethDay - The Ethiopian day of the month. 7 | * @returns {{ year: number, month: number, day: number }} The equivalent Gregorian date. 8 | * @throws {InvalidInputTypeError} If any input is not a number. 9 | * @throws {InvalidEthiopianDateError} If the provided Ethiopian date is invalid. 10 | */ 11 | export function toGC(ethYear: number, ethMonth: number, ethDay: number): { 12 | year: number; 13 | month: number; 14 | day: number; 15 | }; 16 | /** 17 | * Converts a Gregorian date to the Ethiopian calendar (EC) date. 18 | * 19 | * @param {number} gYear - The Gregorian year (e.g., 2024). 20 | * @param {number} gMonth - The Gregorian month (1-12). 21 | * @param {number} gDay - The Gregorian day of the month (1-31). 22 | * @returns {{ year: number, month: number, day: number }} The corresponding Ethiopian calendar date. 23 | * @throws {InvalidInputTypeError} If any input is not a number. 24 | * @throws {InvalidGregorianDateError} If the input date is invalid or out of supported range. 25 | */ 26 | export function toEC(gYear: number, gMonth: number, gDay: number): { 27 | year: number; 28 | month: number; 29 | day: number; 30 | }; 31 | /** 32 | * Converts an Ethiopian date to a Gregorian Calendar JavaScript Date object (UTC). 33 | * 34 | * @param {number} ethYear - The Ethiopian year. 35 | * @param {number} ethMonth - The Ethiopian month (1-based). 36 | * @param {number} ethDay - The Ethiopian day. 37 | * @returns {Date} A JavaScript Date object representing the equivalent Gregorian date in UTC. 38 | */ 39 | export function toGCDate(ethYear: number, ethMonth: number, ethDay: number): Date; 40 | /** 41 | * Converts a JavaScript Date object to the Ethiopian Calendar (EC) date representation. 42 | * 43 | * @param {Date} dateObj - The JavaScript Date object to convert. 44 | * @returns {*} The Ethiopian Calendar date, as returned by the `toEC` function. 45 | */ 46 | export function fromDateToEC(dateObj: Date): any; 47 | /** 48 | * Get Hijri year from a Gregorian date 49 | * @param {Date} date 50 | * @returns {number} hijri year 51 | */ 52 | export function getHijriYear(date: Date): number; 53 | /** 54 | * Converts a Hijri date to the corresponding Gregorian date within a given Gregorian year. 55 | * 56 | * @param {number} hYear - Hijri year (e.g., 1445) 57 | * @param {number} hMonth - Hijri month (1–12) 58 | * @param {number} hDay - Hijri day (1–30) 59 | * @param {number} gregorianYear - Target Gregorian year to restrict the search range 60 | * @returns {Date|null} Gregorian Date object or null if not found 61 | */ 62 | export function hijriToGregorian(hYear: number, hMonth: number, hDay: number, gregorianYear: number): Date | null; 63 | export const islamicFormatter: Intl.DateTimeFormat; 64 | -------------------------------------------------------------------------------- /types/formatting.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats an Ethiopian date using language-specific month name and Arabic numerals. 3 | * 4 | * @param {{year: number, month: number, day: number}} etDate - Ethiopian date object 5 | * @param {'amharic'|'english'} [lang='amharic'] - Language for month name 6 | * @returns {string} Formatted string like "መስከረም 10 2016" 7 | */ 8 | export function formatStandard(etDate: { 9 | year: number; 10 | month: number; 11 | day: number; 12 | }, lang?: "amharic" | "english"): string; 13 | /** 14 | * Formats an Ethiopian date in Geez numerals with Amharic month name. 15 | * 16 | * @param {{year: number, month: number, day: number}} etDate - Ethiopian date 17 | * @returns {string} Example: "መስከረም ፲፩ ፳፻፲፮" 18 | */ 19 | export function formatInGeezAmharic(etDate: { 20 | year: number; 21 | month: number; 22 | day: number; 23 | }): string; 24 | /** 25 | * Formats an Ethiopian date and time as a string. 26 | * 27 | * @param {{year: number, month: number, day: number}} etDate - Ethiopian date 28 | * @param {import('../Time.js').Time} time - An instance of the Time class 29 | * @param {'amharic'|'english'} [lang='amharic'] - Language for suffix 30 | * @returns {string} Example: "መስከረም 10 2016 08:30 ጠዋት" 31 | */ 32 | export function formatWithTime(etDate: { 33 | year: number; 34 | month: number; 35 | day: number; 36 | }, time: any, lang?: "amharic" | "english"): string; 37 | /** 38 | * Formats an Ethiopian date object with the weekday name, month name, day, and year. 39 | * 40 | * @param {Object} etDate - The Ethiopian date object to format. 41 | * @param {number} etDate.day - The day of the month. 42 | * @param {number} etDate.month - The month number (1-based). 43 | * @param {number} etDate.year - The year. 44 | * @param {string} [lang='amharic'] - The language to use for weekday and month names ('amharic', 'english', etc.). 45 | * @param {boolean} [useGeez=false] - Whether to format the day and year in Geez numerals. 46 | * @returns {string} The formatted date string, e.g., "ማክሰኞ, መስከረም 1 2016". 47 | */ 48 | export function formatWithWeekday(etDate: { 49 | day: number; 50 | month: number; 51 | year: number; 52 | }, lang?: string, useGeez?: boolean): string; 53 | /** 54 | * Returns Ethiopian date in short "yyyy/mm/dd" format. 55 | * @param {{year: number, month: number, day: number}} etDate 56 | * @returns {string} e.g., "2017/10/25" 57 | */ 58 | export function formatShort(etDate: { 59 | year: number; 60 | month: number; 61 | day: number; 62 | }): string; 63 | /** 64 | * Returns an ISO-like string: "YYYY-MM-DD" or "YYYY-MM-DDTHH:mm". 65 | * @param {{year: number, month: number, day: number}} etDate 66 | * @param {{hour: number, minute: number, period: 'day'|'night'}|null} time 67 | * @returns {string} 68 | */ 69 | export function toISODateString(etDate: { 70 | year: number; 71 | month: number; 72 | day: number; 73 | }, time?: { 74 | hour: number; 75 | minute: number; 76 | period: "day" | "night"; 77 | } | null): string; 78 | -------------------------------------------------------------------------------- /types/dayArithmetic.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds a specified number of days to an Ethiopian date. 3 | * 4 | * @param {Object} ethiopian - The Ethiopian date object { year, month, day }. 5 | * @param {number} days - The number of days to add. 6 | * @returns {Object} The resulting Ethiopian date. 7 | * @throws {InvalidInputTypeError} If inputs are not of the correct type. 8 | */ 9 | export function addDays(ethiopian: any, days: number): any; 10 | /** 11 | * Adds a specified number of months to an Ethiopian date. 12 | * 13 | * @param {Object} ethiopian - The Ethiopian date object { year, month, day }. 14 | * @param {number} months - The number of months to add. 15 | * @returns {Object} The resulting Ethiopian date. 16 | * @throws {InvalidInputTypeError} If inputs are not of the correct type. 17 | */ 18 | export function addMonths(ethiopian: any, months: number): any; 19 | /** 20 | * Adds a specified number of years to an Ethiopian date. 21 | * 22 | * @param {Object} ethiopian - The Ethiopian date object { year, month, day }. 23 | * @param {number} years - The number of years to add. 24 | * @returns {Object} The resulting Ethiopian date. 25 | * @throws {InvalidInputTypeError} If inputs are not of the correct type. 26 | */ 27 | export function addYears(ethiopian: any, years: number): any; 28 | /** 29 | * Calculates the difference in days between two Ethiopian dates. 30 | * 31 | * @param {Object} a - The first Ethiopian date object. 32 | * @param {Object} b - The second Ethiopian date object. 33 | * @returns {number} The difference in days. 34 | * @throws {InvalidInputTypeError} If inputs are not valid date objects. 35 | */ 36 | export function diffInDays(a: any, b: any): number; 37 | /** 38 | * Calculates the difference in months between two Ethiopian dates. 39 | * 40 | * @param {Object} a - The first Ethiopian date object. 41 | * @param {Object} b - The second Ethiopian date object. 42 | * @returns {number} The difference in months. 43 | * @throws {InvalidInputTypeError} If inputs are not valid date objects. 44 | */ 45 | export function diffInMonths(a: any, b: any): number; 46 | /** 47 | * Calculates the difference in years between two Ethiopian dates. 48 | * 49 | * @param {Object} a - The first Ethiopian date object. 50 | * @param {Object} b - The second Ethiopian date object. 51 | * @returns {number} The difference in years. 52 | * @throws {InvalidInputTypeError} If inputs are not valid date objects. 53 | */ 54 | export function diffInYears(a: any, b: any): number; 55 | /** 56 | * Calculates a human-friendly breakdown between two Ethiopian dates. 57 | * Iteratively accumulates years, then months, then days to avoid off-by-one issues. 58 | * 59 | * @param {Object} a - First Ethiopian date { year, month, day }. 60 | * @param {Object} b - Second Ethiopian date { year, month, day }. 61 | * @param {Object} [options] 62 | * @param {Array<'years'|'months'|'days'>} [options.units=['years','months','days']] - Units to include, in order. 63 | * @returns {{ sign: 1|-1, years?: number, months?: number, days?: number, totalDays: number }} 64 | */ 65 | export function diffBreakdown(a: any, b: any, options?: { 66 | units?: Array<"years" | "months" | "days">; 67 | }): { 68 | sign: 1 | -1; 69 | years?: number; 70 | months?: number; 71 | days?: number; 72 | totalDays: number; 73 | }; 74 | -------------------------------------------------------------------------------- /RELEASE_NOTES_3.2.0.md: -------------------------------------------------------------------------------- 1 | # Kenat v3.2.0 — Distance API and Holiday Helpers 2 | 3 | ## Highlights 4 | - Human-friendly Ethiopian date distance calculations 5 | - Low-level diff utilities for advanced use cases 6 | - Convenience helpers to compute distances to holidays 7 | 8 | ## What’s New 9 | - Distance API on `Kenat` instances: `distanceTo(target, { units, output })` 10 | - Units: `years`, `months`, `days` (any combination) 11 | - Output: `string` (human-friendly) or `object` (structured) 12 | - Low-level utility export: `diffBreakdown(ethiopianDateA, ethiopianDateB)` 13 | - Holiday helpers: 14 | - `Kenat.distanceToHoliday(holidayName, { direction, units, output })` 15 | - `HolidayNames` enum for discoverable holiday keys 16 | 17 | ## Why it matters 18 | Answer questions like “How long until Meskel?” or “How many days since 2016/1/1?” with concise, accurate, and Ethiopian-calendar–aware distance results. APIs are composable and suitable for both UI strings and logical calculations. 19 | 20 | ## Install 21 | ```bash 22 | npm install kenat@^3.2.0 23 | ``` 24 | 25 | ## Usage Examples 26 | ```js 27 | import Kenat, { diffBreakdown, HolidayNames } from 'kenat'; 28 | 29 | // Today in Ethiopian calendar 30 | const today = new Kenat(); 31 | console.log('Today (ET):', today.format({ lang: 'english' })); 32 | 33 | // 1) Difference to a specific Ethiopian date — only days 34 | console.log('Days since 2016/1/1:', today.distanceTo('2016/1/1', { units: ['days'], output: 'string' })); 35 | 36 | // 2) Difference to a future date — months and days 37 | const futureDate = { year: today.getEthiopian().year, month: 13, day: 5 }; 38 | console.log('Until 13/5 (this year):', today.distanceTo(futureDate, { units: ['months', 'days'], output: 'string' })); 39 | 40 | // 3) Full breakdown (years, months, days) as object 41 | const other = new Kenat('2015/5/10'); 42 | console.log('Breakdown (object):', today.distanceTo(other, { units: ['years', 'months', 'days'], output: 'object' })); 43 | 44 | // 4) Using low-level diffBreakdown directly on Ethiopian date objects 45 | console.log('diffBreakdown low-level:', diffBreakdown(today.getEthiopian(), other.getEthiopian())); 46 | 47 | // 5) Holidays — days until next Ethiopian New Year (Enkutatash) 48 | console.log('Days until next Enkutatash:', Kenat.distanceToHoliday(HolidayNames.enkutatash, { direction: 'future', units: ['days'], output: 'string' })); 49 | 50 | // 6) Holidays — how long ago was Meskel (months and days) 51 | console.log('Since last Meskel:', Kenat.distanceToHoliday(HolidayNames.meskel, { direction: 'past', units: ['months', 'days'], output: 'string' })); 52 | 53 | // 7) Holidays — closest Meskel (auto chooses nearest past or future) full breakdown 54 | console.log('Nearest Meskel (full):', Kenat.distanceToHoliday(HolidayNames.meskel, { direction: 'auto', units: ['years', 'months', 'days'], output: 'string' })); 55 | ``` 56 | 57 | ## Compatibility 58 | - No breaking changes expected to existing public APIs 59 | - New exports: `diffBreakdown`, `HolidayNames` 60 | 61 | ## Upgrade Notes 62 | - No code changes required for existing consumers 63 | - For TypeScript users, ensure your types pick up the new exports if you rely on re-export patterns 64 | 65 | ## Testing 66 | ```bash 67 | npm test --silent | cat 68 | ``` 69 | 70 | ## Acknowledgements 71 | Thanks to the community for feedback guiding these features. 72 | -------------------------------------------------------------------------------- /Features.md: -------------------------------------------------------------------------------- 1 | ## add nextMonth, previousMonth, nextYear, previousYear methods for calendar navigation 2 | 3 | ## 🔹 Core Features to Add 4 | 5 | These are essential for most calendar applications. 6 | 7 | 1. **Conversion Enhancements** 8 | 9 | * [ ] Support conversion both ways: `toEC()` and `toGC()` renate this to better dev friendly names like toGregorian() and toEthiopian(). TODO: rename these methods. 10 | 11 | * [ ] Accept JavaScript `Date` object directly (and return one too). 12 | * [ ] Add parsing and formatting helpers for ISO-8601 (`YYYY-MM-DD`). 13 | 14 | 2. **Date Arithmetic** 15 | 16 | * [ ] Add/subtract days, months, years on Ethiopian dates. Add already added, work on subtracting. 17 | * [x] Get difference between two Ethiopian dates in days/months/years. 18 | 19 | 3. **Validation** 20 | 21 | * [ ] Validate Ethiopian dates (e.g. Pagume has 5 or 6 days only). 22 | * [ ] Throw helpful errors for invalid dates. 23 | 24 | 4. **Leap Year Helpers** 25 | 26 | * [x] `.isLeapYear()` method for both Ethiopian and Gregorian dates. 27 | * [ ] `.daysInMonth()` method for any month/year combo. 28 | 29 | --- 30 | 31 | ## 🔹 Display & Formatting Features 32 | 33 | 5. **Localized Formatting** 34 | 35 | * [ ] Support `format()` for multiple languages: `amharic`, `english`, `oromo`, etc. 36 | * [ ] Add options for different formats: long (e.g. “15 Meskerem 2017”), short (e.g. “15/01/2017”), etc. 37 | 38 | 6. **Geez Numerals Everywhere** 39 | 40 | * [x] Add option to display full date in Geez: "መስከረም ፲፭ ፳፻፲፯" and also time in Geez (if relevant). 41 | 42 | 7. **Pretty Today** 43 | 44 | * [x] `Kenat.today()` returns a `Kenat` for current date. 45 | * [ ] `.isToday()` to check if the stored Ethiopian date is today. 46 | 47 | --- 48 | 49 | ## 🔹 Advanced Calendar Features 50 | 51 | 8. **Weekday Support** 52 | 53 | * [ ] `.getWeekday()` – returns day of the week in Amharic or English. 54 | * [ ] Support for calculating holidays based on weekdays (e.g. Meskel always falls on Wednesday one week after finding the true cross). 55 | 56 | 9. **Holiday Support** 57 | 58 | * [x] Built-in support for major Ethiopian holidays (Fasika, Meskel, Timket, Enkutatash, etc.). 59 | * [ ] Ability to list holidays in a given Ethiopian year. / added a method to list in month will list the year too. 60 | 61 | 10. **Week Numbers** 62 | 63 | * [ ] `.getWeekNumber()` for Ethiopian calendar (ISO-style). 64 | 65 | --- 66 | 67 | ## 🔹 Utility / Developer-Friendly Features 68 | 69 | 11. **Static Utilities** 70 | 71 | * [ ] `Kenat.isValidEthiopianDate(y, m, d)` 72 | * [ ] `Kenat.parse(string)` to convert from formatted string. 73 | 74 | 12. **CLI Tool (Optional)** 75 | 76 | * [ ] CLI tool to convert and format dates (`kenat convert 2017/01/15 --to=gregorian`). 77 | 78 | 13. **Calendar View Generator** 79 | 80 | * [x] Function to return an array of days for a given month (e.g., for building UIs). 81 | * [x] Optional metadata (weekday, holiday, isToday, etc.). 82 | 83 | --- 84 | 85 | ## Bonus / Fun Features 86 | 87 | 14. **Date Range Generator** 88 | 89 | * [ ] Generate all dates between two Ethiopian dates. 90 | 91 | 15. **Countdown to Next Holiday** 92 | 93 | * [ ] `.daysUntil('meskel')` or `.daysUntilNextHoliday()` 94 | 95 | 16. **Ethiopian Time Support** 96 | 97 | * [x] Format times in Ethiopian 12-hour system (e.g., “3:00 in the morning” = 9:00 AM Gregorian) -------------------------------------------------------------------------------- /tests/bahireHasab.test.js: -------------------------------------------------------------------------------- 1 | import { getBahireHasab, getMovableHoliday } from '../src/bahireHasab.js'; 2 | import { InvalidInputTypeError } from '../src/errors/errorHandler.js'; 3 | 4 | describe('Bahire Hasab Calculation', () => { 5 | 6 | describe('getBahireHasab for 2016 E.C.', () => { 7 | const bahireHasab2016 = getBahireHasab(2016); 8 | 9 | test('should calculate Amete Alem and Metene Rabiet correctly', () => { 10 | expect(bahireHasab2016.ameteAlem).toBe(7516); 11 | expect(bahireHasab2016.meteneRabiet).toBe(1879); 12 | }); 13 | 14 | test('should identify the correct Evangelist', () => { 15 | expect(bahireHasab2016.evangelist.name).toBe('ዮሐንስ'); // John in Amharic (default) 16 | expect(bahireHasab2016.evangelist.remainder).toBe(0); 17 | }); 18 | 19 | test('should determine the correct New Year day', () => { 20 | expect(bahireHasab2016.newYear.dayName).toBe('ማክሰኞ'); // Tuesday in Amharic (default) 21 | }); 22 | 23 | test('should calculate Medeb, Wenber, Abektie, and Metqi correctly', () => { 24 | expect(bahireHasab2016.medeb).toBe(11); 25 | expect(bahireHasab2016.wenber).toBe(10); 26 | expect(bahireHasab2016.abektie).toBe(20); 27 | expect(bahireHasab2016.metqi).toBe(10); 28 | }); 29 | 30 | test('should calculate the correct date for Nineveh', () => { 31 | expect(bahireHasab2016.nineveh).toEqual({ year: 2016, month: 6, day: 18 }); 32 | }); 33 | }); 34 | 35 | describe('Internationalization (i18n)', () => { 36 | test('should return names in English when specified', () => { 37 | const bahireHasabEnglish = getBahireHasab(2016, { lang: 'english' }); 38 | expect(bahireHasabEnglish.evangelist.name).toBe('John'); 39 | expect(bahireHasabEnglish.newYear.dayName).toBe('Tuesday'); 40 | }); 41 | }); 42 | 43 | describe('Movable Feasts Calculation', () => { 44 | const { movableFeasts } = getBahireHasab(2016, { lang: 'english' }); 45 | 46 | test('should return a complete movableFeasts object', () => { 47 | expect(movableFeasts).toBeDefined(); 48 | expect(Object.keys(movableFeasts).length).toBeGreaterThan(5); 49 | }); 50 | 51 | test('should correctly calculate the date for Fasika (Tinsaye)', () => { 52 | const fasika = movableFeasts.fasika; 53 | expect(fasika).toBeDefined(); 54 | expect(fasika.ethiopian).toEqual({ year: 2016, month: 8, day: 27 }); 55 | expect(fasika.name).toBe('Ethiopian Easter'); 56 | expect(fasika.tags).toContain('public'); 57 | }); 58 | 59 | test('should correctly calculate the date for Abiy Tsome', () => { 60 | const abiyTsome = movableFeasts.abiyTsome; 61 | expect(abiyTsome).toBeDefined(); 62 | expect(abiyTsome.ethiopian).toEqual({ year: 2016, month: 7, day: 2 }); 63 | expect(abiyTsome.name).toBe('Great Lent'); 64 | }); 65 | }); 66 | 67 | describe('Error Handling', () => { 68 | test('should throw InvalidInputTypeError for non-numeric input', () => { 69 | expect(() => getBahireHasab('2016')).toThrow(InvalidInputTypeError); 70 | expect(() => getMovableHoliday('TINSAYE', '2016')).toThrow(InvalidInputTypeError); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/geezConverter.test.js: -------------------------------------------------------------------------------- 1 | import { toGeez, toArabic } from '../src/geezConverter'; 2 | import { GeezConverterError } from '../src/errors/errorHandler.js'; 3 | 4 | 5 | describe('toGeez', () => { 6 | it('converts single digits correctly', () => { 7 | expect(toGeez(1)).toBe('፩'); 8 | expect(toGeez(2)).toBe('፪'); 9 | expect(toGeez(9)).toBe('፱'); 10 | }); 11 | 12 | it('converts tens correctly', () => { 13 | expect(toGeez(10)).toBe('፲'); 14 | expect(toGeez(20)).toBe('፳'); 15 | expect(toGeez(99)).toBe('፺፱'); 16 | }); 17 | 18 | it('converts hundreds correctly', () => { 19 | expect(toGeez(100)).toBe('፻'); 20 | expect(toGeez(101)).toBe('፻፩'); 21 | expect(toGeez(110)).toBe('፻፲'); 22 | expect(toGeez(123)).toBe('፻፳፫'); 23 | expect(toGeez(999)).toBe('፱፻፺፱'); 24 | }); 25 | 26 | it('converts thousands and ten thousands correctly', () => { 27 | expect(toGeez(1000)).toBe('፲፻'); 28 | expect(toGeez(10000)).toBe('፼'); 29 | }); 30 | 31 | it('returns "0" for input 0', () => { 32 | expect(toGeez(0)).toBe('0'); 33 | }); 34 | 35 | it('accepts string input', () => { 36 | expect(toGeez('123')).toBe('፻፳፫'); 37 | expect(toGeez('10000')).toBe('፼'); 38 | }); 39 | 40 | it('throws error for invalid input', () => { 41 | expect(() => toGeez(-1)).toThrow(GeezConverterError); 42 | expect(() => toGeez('abc')).toThrow(GeezConverterError); 43 | expect(() => toGeez(null)).toThrow(); 44 | expect(() => toGeez(undefined)).toThrow(); 45 | expect(() => toGeez(1.5)).toThrow(GeezConverterError); 46 | }); 47 | }); 48 | 49 | describe('toArabic', () => { 50 | it('converts single Ge\'ez numerals to Arabic', () => { 51 | expect(toArabic('፩')).toBe(1); 52 | expect(toArabic('፪')).toBe(2); 53 | expect(toArabic('፱')).toBe(9); 54 | }); 55 | 56 | it('converts Ge\'ez tens to Arabic', () => { 57 | expect(toArabic('፲')).toBe(10); 58 | expect(toArabic('፳')).toBe(20); 59 | expect(toArabic('፺፱')).toBe(99); 60 | }); 61 | 62 | it('converts Ge\'ez hundreds to Arabic', () => { 63 | expect(toArabic('፻')).toBe(100); 64 | expect(toArabic('፻፩')).toBe(101); 65 | expect(toArabic('፻፲')).toBe(110); 66 | expect(toArabic('፻፳፫')).toBe(123); 67 | expect(toArabic('፱፻፺፱')).toBe(999); 68 | }); 69 | 70 | it('converts Ge\'ez thousands and ten thousands to Arabic', () => { 71 | expect(toArabic('፲፻')).toBe(1000); 72 | expect(toArabic('፼')).toBe(10000); 73 | expect(toArabic('፲፼')).toBe(100000); 74 | }); 75 | 76 | it('handles complex numbers', () => { 77 | expect(toArabic('፲፻፺፱')).toBe(1099); 78 | expect(toArabic('፬፻')).toBe(300 + 100); 79 | }); 80 | 81 | it('throws error for unknown Ge\'ez numerals', () => { 82 | expect(() => toArabic('A')).toThrow('Unknown Ge\'ez numeral: A'); 83 | expect(() => toArabic('፩X')).toThrow('Unknown Ge\'ez numeral: X'); 84 | }); 85 | 86 | it('throws error for non-string input', () => { 87 | expect(() => toArabic(null)).toThrow(GeezConverterError); 88 | expect(() => toArabic(undefined)).toThrow(GeezConverterError); 89 | expect(() => toArabic(123)).toThrow(GeezConverterError); 90 | }); 91 | 92 | it('converts round-trip toGeez -> toArabic', () => { 93 | for (let n of [1, 10, 99, 100, 123, 999, 1000, 10000, 12345, 999999]) { 94 | expect(toArabic(toGeez(n))).toBe(n); 95 | } 96 | }); 97 | }); -------------------------------------------------------------------------------- /src/formatting.js: -------------------------------------------------------------------------------- 1 | import { toGeez } from './geezConverter.js'; 2 | import { monthNames } from './constants.js'; 3 | import { getWeekday } from './utils.js'; 4 | import { daysOfWeek } from './constants.js'; 5 | 6 | /** 7 | * Formats an Ethiopian date using language-specific month name and Arabic numerals. 8 | * 9 | * @param {{year: number, month: number, day: number}} etDate - Ethiopian date object 10 | * @param {'amharic'|'english'} [lang='amharic'] - Language for month name 11 | * @returns {string} Formatted string like "መስከረም 10 2016" 12 | */ 13 | export function formatStandard(etDate, lang = 'amharic') { 14 | const names = monthNames[lang] || monthNames.amharic; 15 | const monthName = names[etDate.month - 1] || `Month${etDate.month}`; 16 | return `${monthName} ${etDate.day} ${etDate.year}`; 17 | } 18 | 19 | /** 20 | * Formats an Ethiopian date in Geez numerals with Amharic month name. 21 | * 22 | * @param {{year: number, month: number, day: number}} etDate - Ethiopian date 23 | * @returns {string} Example: "መስከረም ፲፩ ፳፻፲፮" 24 | */ 25 | export function formatInGeezAmharic(etDate) { 26 | const monthName = monthNames.amharic[etDate.month - 1] || `Month${etDate.month}`; 27 | return `${monthName} ${toGeez(etDate.day)} ${toGeez(etDate.year)}`; 28 | } 29 | 30 | /** 31 | * Formats an Ethiopian date and time as a string. 32 | * 33 | * @param {{year: number, month: number, day: number}} etDate - Ethiopian date 34 | * @param {import('../Time.js').Time} time - An instance of the Time class 35 | * @param {'amharic'|'english'} [lang='amharic'] - Language for suffix 36 | * @returns {string} Example: "መስከረም 10 2016 08:30 ጠዋት" 37 | */ 38 | export function formatWithTime(etDate, time, lang = 'amharic') { 39 | const base = formatStandard(etDate, lang); 40 | 41 | // THIS IS THE FIX: Ensure zeroAsDash is false for this specific format. 42 | const timeString = time.format({ 43 | lang, 44 | useGeez: false, 45 | zeroAsDash: false 46 | }); 47 | 48 | return `${base} ${timeString}`; 49 | } 50 | 51 | /** 52 | * Formats an Ethiopian date object with the weekday name, month name, day, and year. 53 | * 54 | * @param {Object} etDate - The Ethiopian date object to format. 55 | * @param {number} etDate.day - The day of the month. 56 | * @param {number} etDate.month - The month number (1-based). 57 | * @param {number} etDate.year - The year. 58 | * @param {string} [lang='amharic'] - The language to use for weekday and month names ('amharic', 'english', etc.). 59 | * @param {boolean} [useGeez=false] - Whether to format the day and year in Geez numerals. 60 | * @returns {string} The formatted date string, e.g., "ማክሰኞ, መስከረም 1 2016". 61 | */ 62 | export function formatWithWeekday(etDate, lang = 'amharic', useGeez = false) { 63 | const weekdayIndex = getWeekday(etDate); 64 | const weekdayName = daysOfWeek[lang]?.[weekdayIndex] || daysOfWeek.amharic[weekdayIndex]; 65 | const monthName = monthNames[lang]?.[etDate.month - 1] || `Month${etDate.month}`; 66 | const day = useGeez ? toGeez(etDate.day) : etDate.day; 67 | const year = useGeez ? toGeez(etDate.year) : etDate.year; 68 | 69 | return `${weekdayName}, ${monthName} ${day} ${year}`; 70 | } 71 | 72 | /** 73 | * Returns Ethiopian date in short "yyyy/mm/dd" format. 74 | * @param {{year: number, month: number, day: number}} etDate 75 | * @returns {string} e.g., "2017/10/25" 76 | */ 77 | export function formatShort(etDate) { 78 | const y = etDate.year; 79 | const m = etDate.month.toString().padStart(2, '0'); 80 | const d = etDate.day.toString().padStart(2, '0'); 81 | return `${y}/${m}/${d}`; 82 | } 83 | 84 | /** 85 | * Returns an ISO-like string: "YYYY-MM-DD" or "YYYY-MM-DDTHH:mm". 86 | * @param {{year: number, month: number, day: number}} etDate 87 | * @param {{hour: number, minute: number, period: 'day'|'night'}|null} time 88 | * @returns {string} 89 | */ 90 | export function toISODateString(etDate, time = null) { 91 | const y = etDate.year; 92 | const m = etDate.month.toString().padStart(2, '0'); 93 | const d = etDate.day.toString().padStart(2, '0'); 94 | 95 | if (!time) return `${y}-${m}-${d}`; 96 | 97 | const hr = time.hour.toString().padStart(2, '0'); 98 | const min = time.minute.toString().padStart(2, '0'); 99 | const suffix = time.period === 'night' ? '+12h' : ''; 100 | 101 | return `${y}-${m}-${d}T${hr}:${min}${suffix}`; 102 | } -------------------------------------------------------------------------------- /tests/Kenat.test.js: -------------------------------------------------------------------------------- 1 | import { Kenat } from '../src/Kenat.js'; 2 | 3 | describe('Kenat class', () => { 4 | test('should create an instance with current date', () => { 5 | const now = new Date(); 6 | const kenat = new Kenat(); 7 | 8 | const gregorian = kenat.getGregorian(); 9 | expect(gregorian).toHaveProperty('year'); 10 | expect(gregorian).toHaveProperty('month'); 11 | expect(gregorian).toHaveProperty('day'); 12 | 13 | // Compare only the date portion 14 | expect(`${gregorian.year}-${gregorian.month}-${gregorian.day}`).toBe( 15 | `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}` 16 | ); 17 | 18 | const ethiopian = kenat.getEthiopian(); 19 | expect(ethiopian).toHaveProperty('year'); 20 | expect(ethiopian).toHaveProperty('month'); 21 | expect(ethiopian).toHaveProperty('day'); 22 | }); 23 | 24 | test('should convert a specific Ethiopian date correctly', () => { 25 | const kenat = new Kenat("2016/9/15"); 26 | const gregorian = kenat.getGregorian(); 27 | expect(gregorian).toEqual({ year: 2024, month: 5, day: 23 }); 28 | 29 | const ethiopian = kenat.getEthiopian(); 30 | expect(ethiopian).toEqual({ year: 2016, month: 9, day: 15 }); 31 | }); 32 | 33 | test('toString should return Ethiopian date string', () => { 34 | const kenat = new Kenat("2016/9/15"); 35 | const str = kenat.toString(); 36 | expect(str).toBe("ግንቦት 15 2016 12:00 ጠዋት"); 37 | }); 38 | 39 | test('format returns Ethiopian date string with month name in English and Amharic', () => { 40 | const kenat = new Kenat("2017/1/15"); // Meskerem 15, 2017 41 | 42 | const englishFormat = kenat.format({ lang: 'english' }); 43 | expect(englishFormat).toBe("Meskerem 15 2017"); 44 | 45 | const amharicFormat = kenat.format({ lang: 'amharic' }); 46 | expect(amharicFormat).toBe("መስከረም 15 2017"); 47 | }); 48 | 49 | test('formatInGeezAmharic returns Ethiopian date string with Amharic month and Geez numerals', () => { 50 | const kenat = new Kenat("2017/1/15"); // Meskerem 15, 2017 51 | 52 | const formatted = kenat.formatInGeezAmharic(); 53 | 54 | // Expected: "መስከረም ፲፭ ፳፻፲፯" 55 | expect(formatted).toBe("መስከረም ፲፭ ፳፻፲፯"); 56 | }); 57 | 58 | }); 59 | 60 | describe('Kenat API Helper Methods', () => { 61 | const date1 = new Kenat("2016/8/15"); 62 | const date2 = new Kenat("2016/8/20"); 63 | const date3 = new Kenat("2016/8/15"); 64 | const leapYearDate = new Kenat("2015/1/1"); 65 | const nonLeapYearDate = new Kenat("2016/1/1"); 66 | 67 | test('isBefore() should correctly compare dates', () => { 68 | expect(date1.isBefore(date2)).toBe(true); 69 | expect(date2.isBefore(date1)).toBe(false); 70 | expect(date1.isBefore(date3)).toBe(false); 71 | }); 72 | 73 | test('isAfter() should correctly compare dates', () => { 74 | expect(date2.isAfter(date1)).toBe(true); 75 | expect(date1.isAfter(date2)).toBe(false); 76 | expect(date1.isAfter(date3)).toBe(false); 77 | }); 78 | 79 | test('isSameDay() should correctly compare dates', () => { 80 | expect(date1.isSameDay(date3)).toBe(true); 81 | expect(date1.isSameDay(date2)).toBe(false); 82 | }); 83 | 84 | test('startOfMonth() should return the first day of the month', () => { 85 | const start = date1.startOfMonth(); 86 | expect(start.getEthiopian()).toEqual({ year: 2016, month: 8, day: 1 }); 87 | }); 88 | 89 | test('endOfMonth() should return the last day of a standard month', () => { 90 | const end = date1.endOfMonth(); 91 | expect(end.getEthiopian()).toEqual({ year: 2016, month: 8, day: 30 }); 92 | }); 93 | 94 | test('endOfMonth() should return the last day of Pagume in a leap year', () => { 95 | const pagume = new Kenat("2015/13/1"); // 2015 is a leap year 96 | const end = pagume.endOfMonth(); 97 | expect(end.getEthiopian()).toEqual({ year: 2015, month: 13, day: 6 }); 98 | }); 99 | 100 | test('isLeapYear() should correctly identify leap years', () => { 101 | expect(leapYearDate.isLeapYear()).toBe(true); 102 | expect(nonLeapYearDate.isLeapYear()).toBe(false); 103 | }); 104 | 105 | test('weekday() should return the correct day of the week', () => { 106 | // May 23, 2024 is a Thursday, which is index 4 107 | const specificDate = new Kenat("2016/9/15"); 108 | expect(specificDate.weekday()).toBe(4); 109 | }); 110 | }); -------------------------------------------------------------------------------- /types/Time.d.ts: -------------------------------------------------------------------------------- 1 | export class Time { 2 | /** 3 | * Creates a Time instance from a Gregorian 24-hour time. 4 | * @param {number} hour - The Gregorian hour (0-23). 5 | * @param {number} [minute=0] - The minute (0-59). 6 | * @returns {Time} A new Time instance. 7 | * @throws {InvalidTimeError} If the Gregorian time is invalid. 8 | */ 9 | static fromGregorian(hour: number, minute?: number): Time; 10 | /** 11 | * Creates a `Time` object from a string representation. 12 | * 13 | * This static method parses a time string, which can include hours, minutes, and an optional period (day/night). 14 | * It supports both Arabic numerals (e.g., "1", "30") and Ethiopic numerals (e.g., "፩", "፴") for hours and minutes, 15 | * assuming a `toArabic` utility function is available to convert Ethiopic numerals to Arabic numbers. 16 | * 17 | * The time string must contain a colon (`:`) separating the hour and minute. 18 | * 19 | * @static 20 | * @param {string} timeString - The string representation of the time. 21 | * Expected formats: 22 | * - "HH:MM" (e.g., "6:30", "፮:፴") 23 | * - "HH:MM period" (e.g., "6:30 night", "፮:፴ ማታ") 24 | * Where: 25 | * - HH: Hour (Arabic or Ethiopic numeral). 26 | * - MM: Minute (Arabic or Ethiopic numeral). 27 | * - period: Optional. Case-insensitive. Recognized values are "night" or "ማታ". 28 | * If the period is omitted, or if a third part is present but not recognized as "night" or "ማታ", 29 | * the time is assumed to be in the 'day' period. 30 | * 31 | * @returns {Time} A new `Time` object representing the parsed time. 32 | * 33 | * @throws {InvalidTimeError} If the `timeString` is: 34 | * - Not a string or an empty string. 35 | * - Missing the colon (`:`) separator. 36 | * - Formatted incorrectly (e.g., not enough parts after splitting). 37 | * - Contains non-numeric values for hour or minute that cannot be parsed into numbers 38 | * (neither as Arabic nor as Ethiopic numerals via `toArabic`). 39 | * 40 | */ 41 | static fromString(timeString: string): Time; 42 | /** 43 | * Constructs a Time instance representing an Ethiopian time. 44 | * @param {number} hour - The Ethiopian hour (1-12). 45 | * @param {number} [minute=0] - The minute (0-59). 46 | * @param {string} [period='day'] - The period ('day' or 'night'). 47 | * @throws {InvalidTimeError} If any time component is invalid. 48 | */ 49 | constructor(hour: number, minute?: number, period?: string); 50 | hour: number; 51 | minute: number; 52 | period: string; 53 | /** 54 | * Converts the Ethiopian time to Gregorian 24-hour format. 55 | * @returns {{hour: number, minute: number}} 56 | */ 57 | toGregorian(): { 58 | hour: number; 59 | minute: number; 60 | }; 61 | /** 62 | * Adds a duration to the current time. 63 | * @param {{hours?: number, minutes?: number}} duration - Object with hours and/or minutes to add. 64 | * @returns {Time} A new Time instance with the added duration. 65 | */ 66 | add(duration: { 67 | hours?: number; 68 | minutes?: number; 69 | }): Time; 70 | /** 71 | * Subtracts a duration from the current time. 72 | * @param {{hours?: number, minutes?: number}} duration - Object with hours and/or minutes to subtract. 73 | * @returns {Time} A new Time instance with the subtracted duration. 74 | */ 75 | subtract(duration: { 76 | hours?: number; 77 | minutes?: number; 78 | }): Time; 79 | /** 80 | * Calculates the difference between this time and another. 81 | * @param {Time} otherTime - Another Time instance to compare against. 82 | * @returns {{hours: number, minutes: number}} An object with the absolute difference. 83 | */ 84 | diff(otherTime: Time): { 85 | hours: number; 86 | minutes: number; 87 | }; 88 | /** 89 | * Formats the time as a string. 90 | * @param {Object} [options] - Formatting options. 91 | * @param {string} [options.lang] - The language for the period label. Defaults to 'english' if useGeez is false, otherwise 'amharic'. 92 | * @param {boolean} [options.useGeez=true] - Whether to use Ge'ez numerals. 93 | * @param {boolean} [options.showPeriodLabel=true] - Whether to show the period label. 94 | * @param {boolean} [options.zeroAsDash=true] - Whether to represent zero minutes as a dash. 95 | * @returns {string} The formatted time string. 96 | */ 97 | format(options?: { 98 | lang?: string; 99 | useGeez?: boolean; 100 | showPeriodLabel?: boolean; 101 | zeroAsDash?: boolean; 102 | }): string; 103 | } 104 | -------------------------------------------------------------------------------- /tests/methodChaining.test.js: -------------------------------------------------------------------------------- 1 | import { Kenat } from '../src/Kenat.js'; 2 | 3 | describe('Method Chaining Tests', () => { 4 | test('should support chaining add operations', () => { 5 | const date = new Kenat('2017/1/1'); 6 | const future = date.add(7, 'days').add(1, 'months').add(1, 'years'); 7 | 8 | expect(future.getEthiopian()).toEqual({ year: 2018, month: 2, day: 8 }); 9 | }); 10 | 11 | test('should support chaining subtract operations', () => { 12 | const date = new Kenat('2017/1/1'); 13 | const past = date.subtract(7, 'days').subtract(1, 'months').subtract(1, 'years'); 14 | 15 | expect(past.getEthiopian()).toEqual({ year: 2015, month: 11, day: 29 }); 16 | }); 17 | 18 | test('should support mixed add and subtract operations', () => { 19 | const date = new Kenat('2017/1/1'); 20 | const result = date.add(7, 'days').subtract(1, 'months').add(1, 'years'); 21 | 22 | expect(result.getEthiopian()).toEqual({ year: 2017, month: 13, day: 5 }); 23 | }); 24 | 25 | test('should preserve time during arithmetic operations', () => { 26 | const date = new Kenat('2017/1/1', { hour: 3, minute: 30, period: 'day' }); 27 | const future = date.add(7, 'days').add(1, 'months'); 28 | 29 | expect(future.getEthiopian()).toEqual({ year: 2017, month: 2, day: 8 }); 30 | expect(future.time.hour).toBe(3); 31 | expect(future.time.minute).toBe(30); 32 | expect(future.time.period).toBe('day'); 33 | }); 34 | 35 | test('should support chaining with startOf and endOf', () => { 36 | const date = new Kenat('2017/6/15'); 37 | const result = date.startOf('month').add(7, 'days').endOf('day'); 38 | 39 | expect(result.getEthiopian()).toEqual({ year: 2017, month: 6, day: 8 }); 40 | expect(result.time.hour).toBe(12); 41 | expect(result.time.period).toBe('night'); 42 | }); 43 | 44 | test('should support chaining with setTime', () => { 45 | const date = new Kenat('2017/1/1'); 46 | const result = date.add(7, 'days').setTime(6, 30, 'night').add(1, 'months'); 47 | 48 | expect(result.getEthiopian()).toEqual({ year: 2017, month: 2, day: 8 }); 49 | expect(result.time.hour).toBe(6); 50 | expect(result.time.minute).toBe(30); 51 | expect(result.time.period).toBe('night'); 52 | }); 53 | 54 | test('should maintain immutability - original date unchanged', () => { 55 | const original = new Kenat('2017/1/1'); 56 | const modified = original.add(7, 'days').add(1, 'months'); 57 | 58 | expect(original.getEthiopian()).toEqual({ year: 2017, month: 1, day: 1 }); 59 | expect(modified.getEthiopian()).toEqual({ year: 2017, month: 2, day: 8 }); 60 | }); 61 | 62 | test('should handle leap year correctly in chaining', () => { 63 | const date = new Kenat('2015/13/6'); // Leap year Pagume 64 | const result = date.add(1, 'days').add(1, 'months'); 65 | 66 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 2, day: 1 }); 67 | }); 68 | 69 | test('should handle month boundaries correctly in chaining', () => { 70 | const date = new Kenat('2017/1/30'); // Last day of Meskerem 71 | const result = date.add(1, 'days').add(1, 'months'); 72 | 73 | expect(result.getEthiopian()).toEqual({ year: 2017, month: 3, day: 1 }); 74 | }); 75 | 76 | test('should support complex chaining operations', () => { 77 | const date = new Kenat('2017/1/1'); 78 | const result = date 79 | .startOf('month') 80 | .add(14, 'days') 81 | .setTime(12, 0, 'day') 82 | .add(1, 'months') 83 | .endOf('day') 84 | .add(1, 'years'); 85 | 86 | expect(result.getEthiopian()).toEqual({ year: 2018, month: 2, day: 15 }); 87 | expect(result.time.hour).toBe(12); 88 | expect(result.time.period).toBe('night'); 89 | }); 90 | 91 | test('should throw error for invalid unit in add()', () => { 92 | const date = new Kenat('2017/1/1'); 93 | expect(() => date.add(1, 'weeks')).toThrow('Invalid unit: weeks'); 94 | }); 95 | 96 | test('should throw error for invalid unit in startOf()', () => { 97 | const date = new Kenat('2017/1/1'); 98 | expect(() => date.startOf('week')).toThrow('Invalid unit: week'); 99 | }); 100 | 101 | test('should throw error for invalid unit in endOf()', () => { 102 | const date = new Kenat('2017/1/1'); 103 | expect(() => date.endOf('week')).toThrow('Invalid unit: week'); 104 | }); 105 | 106 | test('should throw error for non-numeric amount in add()', () => { 107 | const date = new Kenat('2017/1/1'); 108 | expect(() => date.add('seven', 'days')).toThrow('Amount must be a number'); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/geezConverter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ethiopianNumberConverter.js 3 | * 4 | * Converts Arabic numerals (natural numbers) to their equivalent Ethiopic numerals. 5 | * Supports numbers from 1 up to 99999999. 6 | * 7 | * Example: 8 | * toGeez(1); // '፩' 9 | * toGeez(30); // '፴' 10 | * toGeez(123); // '፻፳፫' 11 | * toGeez(10000); // '፼' 12 | * 13 | * @author Melaku Demeke 14 | * @license MIT 15 | */ 16 | /* /src/geezConverter.js (Updated) */ 17 | /* /src/geezConverter.js (Updated) */ 18 | 19 | import { GeezConverterError } from './errors/errorHandler.js'; 20 | 21 | const symbols = { 22 | ones: ['', '፩', '፪', '፫', '፬', '፭', '፮', '፯', '፰', '፱'], 23 | tens: ['', '፲', '፳', '፴', '፵', '፶', '፷', '፸', '፹', '፺'], 24 | hundred: '፻', 25 | tenThousand: '፼' 26 | }; 27 | 28 | /** 29 | * Converts a natural number to Ethiopic numeral string. 30 | * 31 | * @param {number|string} input - The number to convert (positive integer only). 32 | * @returns {string} Ethiopic numeral string. 33 | * @throws {GeezConverterError} If input is not a valid positive integer. 34 | */ 35 | export function toGeez(input) { 36 | if (typeof input !== 'number' && typeof input !== 'string') { 37 | throw new GeezConverterError("Input must be a number or a string."); 38 | } 39 | 40 | const num = Number(input); 41 | 42 | if (isNaN(num) || !Number.isInteger(num) || num < 0) { 43 | throw new GeezConverterError("Input must be a non-negative integer."); 44 | } 45 | 46 | if (num === 0) return '0'; // Often Ge'ez doesn't have a zero, but useful for modern contexts. 47 | 48 | // Helper for numbers 1-99 49 | function convertBelow100(n) { 50 | if (n <= 0) return ''; 51 | const tensDigit = Math.floor(n / 10); 52 | const onesDigit = n % 10; 53 | return symbols.tens[tensDigit] + symbols.ones[onesDigit]; 54 | } 55 | 56 | if (num < 100) { 57 | return convertBelow100(num); 58 | } 59 | 60 | if (num === 100) return symbols.hundred; 61 | 62 | if (num < 10000) { 63 | const hundreds = Math.floor(num / 100); 64 | const remainder = num % 100; 65 | // For numbers like 101, it's ፻፩, not ፩፻፩. If the hundred part is 1, don't add a prefix. 66 | const hundredPart = (hundreds > 1 ? convertBelow100(hundreds) : '') + symbols.hundred; 67 | return hundredPart + convertBelow100(remainder); 68 | } 69 | 70 | // For numbers >= 10000, use recursion 71 | const tenThousandPart = Math.floor(num / 10000); 72 | const remainder = num % 10000; 73 | 74 | // If the ten-thousand part is 1, no prefix is needed (e.g., ፼, not ፩፼) 75 | const tenThousandGeez = (tenThousandPart > 1 ? toGeez(tenThousandPart) : '') + symbols.tenThousand; 76 | 77 | return tenThousandGeez + (remainder > 0 ? toGeez(remainder) : ''); 78 | } 79 | 80 | 81 | /** 82 | * Converts a Ge'ez numeral string to its Arabic numeral equivalent. 83 | * 84 | * @param {string} geezStr - The Ge'ez numeral string to convert. 85 | * @returns {number} The Arabic numeral representation of the input string. 86 | * @throws {GeezConverterError} If the input is not a valid Ge'ez numeral string. 87 | */ 88 | export function toArabic(geezStr) { 89 | if (typeof geezStr !== 'string') { 90 | throw new GeezConverterError('Input must be a non-empty string.'); 91 | } 92 | if (geezStr.trim() === '') { 93 | return 0; // Or throw error, depending on desired behavior for empty string 94 | } 95 | 96 | const reverseMap = {}; 97 | symbols.ones.forEach((char, i) => { if (char) reverseMap[char] = i; }); 98 | symbols.tens.forEach((char, i) => { if (char) reverseMap[char] = i * 10; }); 99 | reverseMap[symbols.hundred] = 100; 100 | reverseMap[symbols.tenThousand] = 10000; 101 | 102 | let total = 0; 103 | let currentNumber = 0; 104 | 105 | for (const char of geezStr) { 106 | const value = reverseMap[char]; 107 | 108 | if (value === undefined) { 109 | throw new GeezConverterError(`Unknown Ge'ez numeral: ${char}`); 110 | } 111 | 112 | if (value === 100 || value === 10000) { 113 | // If currentNumber is 0, it implies a standalone ፻ or ፼, so treat it as 1 * multiplier. 114 | currentNumber = (currentNumber || 1) * value; 115 | 116 | // ፼ acts as a separator for large numbers. Add the completed segment to the total. 117 | if (value === 10000) { 118 | total += currentNumber; 119 | currentNumber = 0; 120 | } 121 | } else { 122 | // Add simple digit values (1-99) 123 | currentNumber += value; 124 | } 125 | } 126 | 127 | // Add any remaining part (for numbers that don't end in ፼) 128 | total += currentNumber; 129 | return total; 130 | } 131 | -------------------------------------------------------------------------------- /tests/holidays.test.js: -------------------------------------------------------------------------------- 1 | import { getHolidaysInMonth, getHoliday } from '../src/holidays.js'; 2 | import { getMovableHoliday } from '../src/bahireHasab.js'; 3 | import { InvalidInputTypeError } from '../src/errors/errorHandler.js'; 4 | import { UnknownHolidayError } from '../src/errors/errorHandler.js'; 5 | 6 | describe('Holiday Calculation', () => { 7 | 8 | describe('getHolidaysInMonth', () => { 9 | test('should return fixed and movable holidays for a given month', () => { 10 | // Meskerem 2016 has Enkutatash (day 1) and Meskel (day 17) 11 | const holidays = getHolidaysInMonth(2016, 1); 12 | const holidayKeys = holidays.map(h => h.key); 13 | 14 | expect(holidayKeys).toContain('enkutatash'); 15 | expect(holidayKeys).toContain('meskel'); 16 | }); 17 | 18 | test('should correctly calculate and include movable Christian holidays', () => { 19 | // In 2016 E.C., Fasika is on Miazia 27 and Siklet is on Miazia 25 20 | const holidays = getHolidaysInMonth(2016, 8); 21 | const fasika = holidays.find(h => h.key === 'fasika'); 22 | const siklet = holidays.find(h => h.key === 'siklet'); 23 | 24 | expect(fasika).toBeDefined(); 25 | expect(fasika.ethiopian.day).toBe(27); 26 | 27 | expect(siklet).toBeDefined(); 28 | expect(siklet.ethiopian.day).toBe(25); 29 | }); 30 | }); 31 | 32 | describe('getMovableHoliday (Bahire Hasab)', () => { 33 | test('should return correct Fasika (Tinsaye) date for Ethiopian years 2012 to 2016', () => { 34 | expect(getMovableHoliday('TINSAYE', 2012)).toEqual({ year: 2012, month: 8, day: 11 }); 35 | expect(getMovableHoliday('TINSAYE', 2013)).toEqual({ year: 2013, month: 8, day: 24 }); 36 | expect(getMovableHoliday('TINSAYE', 2014)).toEqual({ year: 2014, month: 8, day: 16 }); 37 | expect(getMovableHoliday('TINSAYE', 2015)).toEqual({ year: 2015, month: 8, day: 8 }); 38 | expect(getMovableHoliday('TINSAYE', 2016)).toEqual({ year: 2016, month: 8, day: 27 }); 39 | }); 40 | 41 | test('should return correct Siklet (Good Friday) date for Ethiopian years 2012 to 2016', () => { 42 | expect(getMovableHoliday('SIKLET', 2012)).toEqual({ year: 2012, month: 8, day: 9 }); 43 | expect(getMovableHoliday('SIKLET', 2013)).toEqual({ year: 2013, month: 8, day: 22 }); 44 | expect(getMovableHoliday('SIKLET', 2014)).toEqual({ year: 2014, month: 8, day: 14 }); 45 | expect(getMovableHoliday('SIKLET', 2015)).toEqual({ year: 2015, month: 8, day: 6 }); 46 | expect(getMovableHoliday('SIKLET', 2016)).toEqual({ year: 2016, month: 8, day: 25 }); 47 | }); 48 | }); 49 | 50 | describe('Error Handling', () => { 51 | test('getHolidaysInMonth should throw for invalid input types', () => { 52 | expect(() => getHolidaysInMonth('2016', 1)).toThrow(InvalidInputTypeError); 53 | expect(() => getHolidaysInMonth(2016, 'one')).toThrow(InvalidInputTypeError); 54 | }); 55 | 56 | test('getHolidaysInMonth should throw for out-of-range month', () => { 57 | expect(() => getHolidaysInMonth(2016, 0)).toThrow(InvalidInputTypeError); 58 | expect(() => getHolidaysInMonth(2016, 14)).toThrow(InvalidInputTypeError); 59 | }); 60 | 61 | test('getMovableHoliday should throw for invalid input type', () => { 62 | expect(() => getMovableHoliday('TINSAYE', null)).toThrow(InvalidInputTypeError); 63 | expect(() => getMovableHoliday('TINSAYE', '2016')).toThrow(InvalidInputTypeError); 64 | }); 65 | 66 | test('getMovableHoliday should throw for unknown holiday key', () => { 67 | expect(() => getMovableHoliday('UNKNOWN_HOLIDAY', 2016)).toThrow(UnknownHolidayError); 68 | }); 69 | }); 70 | 71 | describe('Movable Muslim Holidays', () => { 72 | test('should return correct date for Moulid in 2016', () => { 73 | // Moulid in 2016 E.C. is on Meskerem 17 74 | const holiday = getHoliday('moulid', 2016); 75 | expect(holiday).toBeDefined(); 76 | expect(holiday.ethiopian).toEqual({ year: 2016, month: 1, day: 16 }); 77 | }); 78 | 79 | test('should return correct date for Eid al-Fitr in 2016', () => { 80 | // Eid al-Fitr in 2016 E.C. is on Miazia 2 81 | const holiday = getHoliday('eidFitr', 2016); 82 | expect(holiday).toBeDefined(); 83 | expect(holiday.ethiopian).toEqual({ year: 2016, month: 8, day: 1 }); 84 | }); 85 | 86 | test('should return correct date for Eid al-Adha in 2016', () => { 87 | // Eid al-Adha in 2016 E.C. is on Sene 9 88 | const holiday = getHoliday('eidAdha', 2016); 89 | expect(holiday).toBeDefined(); 90 | expect(holiday.ethiopian).toEqual({ year: 2016, month: 10, day: 9 }); 91 | }); 92 | }); 93 | 94 | }); 95 | -------------------------------------------------------------------------------- /types/utils.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates that all provided date parts are numbers. 3 | * @param {string} funcName - The name of the function being validated. 4 | * @param {Object} dateParts - An object where keys are param names and values are the inputs. 5 | * @throws {InvalidInputTypeError} if any value is not a number. 6 | */ 7 | export function validateNumericInputs(funcName: string, dateParts: any): void; 8 | /** 9 | * Validates that the input is a valid Ethiopian date object. 10 | * @param {Object} dateObj - The object to validate. 11 | * @param {string} funcName - The name of the function being validated. 12 | * @param {string} paramName - The name of the parameter being validated. 13 | * @throws {InvalidInputTypeError} if the object is invalid. 14 | */ 15 | export function validateEthiopianDateObject(dateObj: any, funcName: string, paramName: string): void; 16 | /** 17 | * Validates that the input is a valid Ethiopian time object. 18 | * @param {Object} timeObj - The object to validate. 19 | * @param {string} funcName - The name of the function being validated. 20 | * @param {string} paramName - The name of the parameter being validated. 21 | * @throws {InvalidInputTypeError} if the object is invalid. 22 | */ 23 | export function validateEthiopianTimeObject(timeObj: any, funcName: string, paramName: string): void; 24 | /** 25 | * Calculates the day of the year for a given date. 26 | * 27 | * @param {number} year - The full year (e.g., 2024). 28 | * @param {number} month - The month (1-based, January is 1, December is 12). 29 | * @param {number} day - The day of the month. 30 | * @returns {number} The day of the year (1-based). 31 | */ 32 | export function dayOfYear(year: number, month: number, day: number): number; 33 | /** 34 | * Convert a day of year to Gregorian month and day. 35 | */ 36 | export function monthDayFromDayOfYear(year: any, dayOfYear: any): { 37 | month: number; 38 | day: any; 39 | }; 40 | /** 41 | * Checks if the given Gregorian year is a leap year. 42 | * 43 | * Gregorian leap years occur every 4 years, except centuries not divisible by 400. 44 | * For example: 2000 is a leap year, 1900 is not. 45 | * 46 | * @param {number} year - Gregorian calendar year (e.g., 2025) 47 | * @returns {boolean} - True if the year is a leap year, otherwise false. 48 | */ 49 | export function isGregorianLeapYear(year: number): boolean; 50 | /** 51 | * Checks if the given Ethiopian year is a leap year. 52 | * 53 | * Ethiopian leap years occur every 4 years, when the year modulo 4 equals 3. 54 | * This means years like 2011, 2015, 2019 (in Ethiopian calendar) are leap years. 55 | * 56 | * @param {number} year - Ethiopian calendar year (e.g., 2011) 57 | * @returns {boolean} - True if the year is a leap year, otherwise false. 58 | */ 59 | export function isEthiopianLeapYear(year: number): boolean; 60 | /** 61 | * Returns the number of days in the given Ethiopian month and year. 62 | * @param {number} year - Ethiopian year 63 | * @param {number} month - Ethiopian month (1-13) 64 | * @returns {number} Number of days in the month 65 | */ 66 | export function getEthiopianDaysInMonth(year: number, month: number): number; 67 | /** 68 | * Returns the weekday (0-6) for a given Ethiopian date. 69 | * 70 | * @param {Object} param0 - The Ethiopian date. 71 | * @param {number} param0.year - The Ethiopian year. 72 | * @param {number} param0.month - The Ethiopian month (1-13). 73 | * @param {number} param0.day - The Ethiopian day (1-30). 74 | * @returns {number} The day of the week (0 for Sunday, 6 for Saturday). 75 | */ 76 | export function getWeekday({ year, month, day }: { 77 | year: number; 78 | month: number; 79 | day: number; 80 | }): number; 81 | /** 82 | * Checks if a given Ethiopian date is valid. 83 | * @param {number} year - Ethiopian year 84 | * @param {number} month - Ethiopian month (1-13) 85 | * @param {number} day - Ethiopian day (1-30 or 1-5/6) 86 | * @returns {boolean} - True if the date is valid, otherwise false. 87 | */ 88 | export function isValidEthiopianDate(year: number, month: number, day: number): boolean; 89 | /** 90 | * Helper: Get Ethiopian New Year for a Gregorian year. 91 | * @param {number} gYear - The Gregorian year. 92 | * @returns {{gregorianYear: number, month: number, day: number}} 93 | * @throws {InvalidInputTypeError} If gYear is not a number. 94 | */ 95 | export function getEthiopianNewYearForGregorian(gYear: number): { 96 | gregorianYear: number; 97 | month: number; 98 | day: number; 99 | }; 100 | /** 101 | * Returns the Gregorian date of the Ethiopian New Year for the given Ethiopian year. 102 | * 103 | * @param {number} ethiopianYear - Ethiopian calendar year. 104 | * @returns {{gregorianYear: number, month: number, day: number}} 105 | * @throws {InvalidInputTypeError} If ethiopianYear is not a number. 106 | */ 107 | export function getGregorianDateOfEthiopianNewYear(ethiopianYear: number): { 108 | gregorianYear: number; 109 | month: number; 110 | day: number; 111 | }; 112 | -------------------------------------------------------------------------------- /src/errors/errorHandler.js: -------------------------------------------------------------------------------- 1 | /* /src/errors/index.js */ 2 | 3 | /** 4 | * Base class for all custom errors in the Kenat library. 5 | */ 6 | export class KenatError extends Error { 7 | constructor(message) { 8 | super(message); 9 | this.name = this.constructor.name; 10 | } 11 | 12 | /** 13 | * Provides a serializable representation of the error. 14 | * @returns {Object} A plain object with error details. 15 | */ 16 | toJSON() { 17 | return { 18 | type: this.name, 19 | message: this.message, 20 | }; 21 | } 22 | } 23 | 24 | /** 25 | * Thrown when an Ethiopian date is numerically invalid (e.g., month 14). 26 | */ 27 | export class InvalidEthiopianDateError extends KenatError { 28 | constructor(year, month, day) { 29 | super(`Invalid Ethiopian date: ${year}/${month}/${day}`); 30 | this.date = { year, month, day }; 31 | } 32 | 33 | toJSON() { 34 | return { 35 | ...super.toJSON(), 36 | date: this.date, 37 | validRange: { 38 | month: "1–13", 39 | day: "1–30 (or 5/6 for the 13th month)", 40 | }, 41 | }; 42 | } 43 | } 44 | 45 | /** 46 | * Thrown when a Gregorian date is numerically invalid. 47 | */ 48 | export class InvalidGregorianDateError extends KenatError { 49 | constructor(year, month, day) { 50 | super(`Invalid Gregorian date: ${year}/${month}/${day}`); 51 | this.date = { year, month, day }; 52 | } 53 | 54 | toJSON() { 55 | return { 56 | ...super.toJSON(), 57 | date: this.date, 58 | validRange: { 59 | month: "1–12", 60 | day: "1–31 (depending on month)", 61 | }, 62 | }; 63 | } 64 | } 65 | 66 | /** 67 | * Thrown when a date string provided to the constructor has an invalid format. 68 | */ 69 | export class InvalidDateFormatError extends KenatError { 70 | constructor(inputString) { 71 | super(`Invalid date string format: "${inputString}". Expected 'yyyy/mm/dd' or 'yyyy-mm-dd'.`); 72 | this.inputString = inputString; 73 | } 74 | 75 | toJSON() { 76 | return { 77 | ...super.toJSON(), 78 | inputString: this.inputString, 79 | }; 80 | } 81 | } 82 | 83 | /** 84 | * Thrown when the Kenat constructor receives an input type it cannot handle. 85 | */ 86 | export class UnrecognizedInputError extends KenatError { 87 | constructor(input) { 88 | const inputType = typeof input; 89 | super(`Unrecognized input type for Kenat constructor: ${inputType}`); 90 | this.input = input; 91 | } 92 | 93 | toJSON() { 94 | return { 95 | ...super.toJSON(), 96 | inputType: typeof this.input, 97 | }; 98 | } 99 | } 100 | 101 | /** 102 | * Thrown for errors occurring during Ge'ez numeral conversion. 103 | */ 104 | export class GeezConverterError extends KenatError { 105 | constructor(message) { 106 | super(message); 107 | } 108 | } 109 | 110 | /** 111 | * Thrown when a function receives an argument of an incorrect type. 112 | */ 113 | export class InvalidInputTypeError extends KenatError { 114 | constructor(functionName, parameterName, expectedType, receivedValue) { 115 | const receivedType = typeof receivedValue; 116 | super(`Invalid type for parameter '${parameterName}' in function '${functionName}'. Expected '${expectedType}' but got '${receivedType}'.`); 117 | this.functionName = functionName; 118 | this.parameterName = parameterName; 119 | this.expectedType = expectedType; 120 | this.receivedValue = receivedValue; 121 | } 122 | 123 | toJSON() { 124 | return { 125 | ...super.toJSON(), 126 | functionName: this.functionName, 127 | parameterName: this.parameterName, 128 | expectedType: this.expectedType, 129 | receivedType: typeof this.receivedValue, 130 | }; 131 | } 132 | } 133 | 134 | /** 135 | * Thrown for errors related to invalid time components. 136 | */ 137 | export class InvalidTimeError extends KenatError { 138 | constructor(message) { 139 | super(message); 140 | } 141 | } 142 | 143 | /** 144 | * Thrown for invalid configuration options passed to MonthGrid. 145 | */ 146 | export class InvalidGridConfigError extends KenatError { 147 | constructor(message) { 148 | super(message); 149 | } 150 | } 151 | 152 | /** 153 | * Thrown when an unknown holiday key is used. 154 | */ 155 | export class UnknownHolidayError extends KenatError { 156 | constructor(holidayKey) { 157 | super(`Unknown movable holiday key: "${holidayKey}"`); 158 | this.holidayKey = holidayKey; 159 | } 160 | 161 | toJSON() { 162 | return { 163 | ...super.toJSON(), 164 | holidayKey: this.holidayKey, 165 | }; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tests/monthgrid.test.js: -------------------------------------------------------------------------------- 1 | import { Kenat } from '../src/Kenat.js'; 2 | 3 | // Mock daysOfWeek and getWeekday if they are global or imported in your module 4 | const daysOfWeek = { 5 | amharic: ['ሰኞ', 'ማክሰኞ', 'እሮብ', 'ሐሙስ', 'ዓርብ', 'ቅዳሜ', 'እሑድ'], 6 | english: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], 7 | // add others if needed 8 | }; 9 | const getWeekday = (ethDate) => { 10 | // Simplified mock: returns 0..6 cyclic by day for test purpose 11 | return (ethDate.day - 1) % 7; 12 | }; 13 | 14 | // Mock Kenat.now().getEthiopian() and Kenat constructor for testing 15 | Kenat.now = () => ({ 16 | getEthiopian: () => ({ year: 2015, month: 9, day: 10 }), 17 | }); 18 | Kenat.prototype.getMonthCalendar = function(year, month, useGeez) { 19 | // Return mock days including the current day 10 for the default test 20 | return [ 21 | { ethiopian: { year, month, day: 8 }, someData: 'day8' }, 22 | { ethiopian: { year, month, day: 9 }, someData: 'day9' }, 23 | { ethiopian: { year, month, day: 10 }, someData: 'day10' }, // current day 24 | { ethiopian: { year, month, day: 11 }, someData: 'day11' }, 25 | { ethiopian: { year, month, day: 12 }, someData: 'day12' }, 26 | ]; 27 | }; 28 | Kenat.prototype.constructor = Kenat; 29 | 30 | // Attach dependencies to global or module scope as needed 31 | global.daysOfWeek = daysOfWeek; 32 | global.getWeekday = getWeekday; 33 | 34 | // The method under test 35 | Kenat.getMonthGrid = function(input = {}) { 36 | let year, month, weekStart = 0, useGeez = false, weekdayLang = 'amharic'; 37 | 38 | if (typeof input === 'string') { 39 | const match = input.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/); 40 | if (!match) throw new Error("Invalid Ethiopian date format. Use 'yyyy/mm/dd'"); 41 | year = parseInt(match[1]); 42 | month = parseInt(match[2]); 43 | } else if (typeof input === 'object') { 44 | ({ year, month, weekStart = 0, useGeez = false, weekdayLang = 'amharic' } = input); 45 | } 46 | 47 | const current = Kenat.now().getEthiopian(); 48 | const y = year || current.year; 49 | const m = month || current.month; 50 | 51 | const todayEth = Kenat.now().getEthiopian(); 52 | 53 | const temp = new Kenat(`${y}/${m}/1`); 54 | const days = temp.getMonthCalendar(y, m, useGeez); 55 | 56 | const labels = daysOfWeek[weekdayLang] || daysOfWeek.amharic; 57 | 58 | const daysWithWeekday = days.map(day => { 59 | const weekday = getWeekday(day.ethiopian); 60 | const isToday = 61 | Number(day.ethiopian.year) === Number(todayEth.year) && 62 | Number(day.ethiopian.month) === Number(todayEth.month) && 63 | Number(day.ethiopian.day) === Number(todayEth.day); 64 | 65 | return { 66 | ...day, 67 | weekday, 68 | weekdayName: labels[weekday], 69 | isToday, 70 | }; 71 | }); 72 | 73 | const firstWeekday = daysWithWeekday[0].weekday; 74 | let offset = firstWeekday - weekStart; 75 | if (offset < 0) offset += 7; 76 | 77 | const padded = Array(offset).fill(null).concat(daysWithWeekday); 78 | const headers = labels.slice(weekStart).concat(labels.slice(0, weekStart)); 79 | 80 | return { 81 | headers, 82 | days: padded, 83 | }; 84 | }; 85 | 86 | describe('Kenat.getMonthGrid', () => { 87 | test('returns month grid with default current Ethiopian date when no input', () => { 88 | const result = Kenat.getMonthGrid(); 89 | expect(result).toHaveProperty('headers'); 90 | expect(result).toHaveProperty('days'); 91 | expect(Array.isArray(result.headers)).toBe(true); 92 | expect(Array.isArray(result.days)).toBe(true); 93 | // Check days contain weekdays and isToday flag 94 | expect(result.days.some(d => d && d.isToday)).toBe(true); 95 | }); 96 | 97 | test('parses string input correctly and returns valid grid', () => { 98 | const result = Kenat.getMonthGrid('2015/9/1'); 99 | expect(result.headers.length).toBe(7); 100 | expect(result.days.filter(d => d !== null).length).toBe(5); // mock 5 days 101 | expect(result.days[0]).toHaveProperty('weekday'); 102 | }); 103 | 104 | test('throws error on invalid string format', () => { 105 | expect(() => Kenat.getMonthGrid('invalid-date')).toThrow(/Invalid Ethiopian date format/); 106 | }); 107 | 108 | test('accepts object input with year, month and custom weekStart', () => { 109 | const result = Kenat.getMonthGrid({ year: 2015, month: 9, weekStart: 1, weekdayLang: 'english' }); 110 | expect(result.headers[0]).toBe('Tuesday'); // weekStart=1 shifts headers 111 | expect(result.days.filter(d => d !== null).length).toBe(5); 112 | // Check weekdayName matches English labels 113 | expect(result.days.find(d => d !== null).weekdayName).toBe('Monday'); 114 | }); 115 | 116 | test('uses default amharic weekday names if invalid weekdayLang', () => { 117 | const result = Kenat.getMonthGrid({ year: 2015, month: 9, weekdayLang: 'invalid' }); 118 | expect(result.headers).toEqual(daysOfWeek.amharic); 119 | }); 120 | 121 | test('pads days array correctly based on weekStart', () => { 122 | const resultDefault = Kenat.getMonthGrid({ year: 2015, month: 9, weekStart: 0 }); 123 | const offsetDefault = resultDefault.days.findIndex(day => day !== null); 124 | expect(offsetDefault).toBeGreaterThanOrEqual(0); 125 | const resultShift = Kenat.getMonthGrid({ year: 2015, month: 9, weekStart: 3 }); 126 | const offsetShift = resultShift.days.findIndex(day => day !== null); 127 | expect(offsetShift).toBeGreaterThanOrEqual(0); 128 | expect(offsetShift).not.toBe(offsetDefault); // Should differ if weekStart changed 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/bahireHasab.js: -------------------------------------------------------------------------------- 1 | import { validateNumericInputs, getWeekday } from './utils.js'; 2 | import { addDays } from './dayArithmetic.js'; 3 | import { toGC } from './conversions.js'; 4 | import { UnknownHolidayError } from './errors/errorHandler.js'; 5 | import { 6 | daysOfWeek, 7 | evangelistNames, 8 | tewsakMap, 9 | movableHolidayTewsak, 10 | keyToTewsakMap, 11 | holidayInfo, 12 | movableHolidays 13 | } from './constants.js'; 14 | 15 | /** 16 | * Calculates all Bahire Hasab values for a given Ethiopian year, including all movable feasts. 17 | * 18 | * @param {number} ethiopianYear - The Ethiopian year to calculate for. 19 | * @param {Object} [options={}] - Options for language. 20 | * @param {string} [options.lang='amharic'] - The language for names. 21 | * @returns {Object} An object containing all the calculated Bahire Hasab values. 22 | */ 23 | export function getBahireHasab(ethiopianYear, options = {}) { 24 | validateNumericInputs('getBahireHasab', { ethiopianYear }); 25 | const { lang = 'amharic' } = options; 26 | 27 | const base = _calculateBahireHasabBase(ethiopianYear); 28 | 29 | const evangelistRemainder = base.ameteAlem % 4; 30 | const evangelistName = evangelistNames[lang]?.[evangelistRemainder] || evangelistNames.english[evangelistRemainder]; 31 | 32 | const tinteQemer = (base.ameteAlem + base.meteneRabiet) % 7; 33 | const weekdayIndex = (tinteQemer + 1) % 7; 34 | const newYearWeekday = daysOfWeek[lang]?.[weekdayIndex] || daysOfWeek.english[weekdayIndex]; 35 | 36 | const movableFeasts = {}; 37 | const tewsakToKeyMap = Object.entries(keyToTewsakMap).reduce((acc, [key, val]) => { 38 | acc[val] = key; return acc; 39 | }, {}); 40 | 41 | Object.keys(movableHolidayTewsak).forEach(tewsakKey => { 42 | const holidayKey = tewsakToKeyMap[tewsakKey]; 43 | if (holidayKey) { 44 | const date = addDays(base.ninevehDate, movableHolidayTewsak[tewsakKey]); 45 | const info = holidayInfo[holidayKey]; 46 | const rules = movableHolidays[holidayKey]; 47 | 48 | movableFeasts[holidayKey] = { 49 | key: holidayKey, 50 | tags: rules.tags, 51 | movable: true, 52 | name: info?.name?.[lang] || info?.name?.english, 53 | description: info?.description?.[lang] || info?.description?.english, 54 | ethiopian: date, 55 | gregorian: toGC(date.year, date.month, date.day) 56 | }; 57 | } 58 | }); 59 | 60 | return { 61 | ameteAlem: base.ameteAlem, 62 | meteneRabiet: base.meteneRabiet, 63 | evangelist: { name: evangelistName, remainder: evangelistRemainder }, 64 | newYear: { dayName: newYearWeekday, tinteQemer: tinteQemer }, 65 | medeb: base.medeb, 66 | wenber: base.wenber, 67 | abektie: base.abektie, 68 | metqi: base.metqi, 69 | bealeMetqi: { date: base.bealeMetqiDate, weekday: base.bealeMetqiWeekday }, 70 | mebajaHamer: base.mebajaHamer, 71 | nineveh: base.ninevehDate, 72 | movableFeasts 73 | }; 74 | } 75 | 76 | 77 | /** 78 | * Calculates the date of a movable holiday for a given year. 79 | * This is now a pure date calculator that returns a simple date object, 80 | * ensuring backward compatibility with existing tests. 81 | * 82 | * @param {'ABIY_TSOME'|'TINSAYE'|'ERGET'|...} holidayKey - The key of the holiday from movableHolidayTewsak. 83 | * @param {number} ethiopianYear - The Ethiopian year. 84 | * @returns {Object} An Ethiopian date object { year, month, day }. 85 | */ 86 | export function getMovableHoliday(holidayKey, ethiopianYear) { 87 | validateNumericInputs('getMovableHoliday', { ethiopianYear }); 88 | 89 | const tewsak = movableHolidayTewsak[holidayKey]; 90 | if (tewsak === undefined) { 91 | throw new UnknownHolidayError(holidayKey); 92 | } 93 | 94 | const { ninevehDate } = _calculateBahireHasabBase(ethiopianYear); 95 | 96 | return addDays(ninevehDate, tewsak); 97 | } 98 | 99 | 100 | /** 101 | * Calculates and returns all base values for the Bahire Hasab system for a given Ethiopian year. 102 | * This helper is the single source of truth for the core computational logic. 103 | * 104 | * @param {number} ethiopianYear - The Ethiopian year for which to perform the calculations. 105 | * @returns {{ 106 | * ameteAlem: number, 107 | * meteneRabiet: number, 108 | * medeb: number, 109 | * wenber: number, 110 | * abektie: number, 111 | * metqi: number, 112 | * bealeMetqiDate: { year: number, month: number, day: number }, 113 | * bealeMetqiWeekday: string, 114 | * mebajaHamer: number, 115 | * ninevehDate: { year: number, month: number, day: number } 116 | * }} An object containing all core calculated values. 117 | */ 118 | function _calculateBahireHasabBase(ethiopianYear) { 119 | const ameteAlem = 5500 + ethiopianYear; 120 | const meteneRabiet = Math.floor(ameteAlem / 4); 121 | const medeb = ameteAlem % 19; 122 | const wenber = medeb === 0 ? 18 : medeb - 1; 123 | const abektie = (wenber * 11) % 30; 124 | const metqi = (wenber * 19) % 30; 125 | 126 | const bealeMetqiMonth = metqi > 14 ? 1 : 2; 127 | const bealeMetqiDay = metqi; 128 | const bealeMetqiDate = { year: ethiopianYear, month: bealeMetqiMonth, day: bealeMetqiDay }; 129 | 130 | const bealeMetqiWeekday = daysOfWeek.english[getWeekday(bealeMetqiDate)]; 131 | const tewsak = tewsakMap[bealeMetqiWeekday]; 132 | const mebajaHamerSum = bealeMetqiDay + tewsak; 133 | const mebajaHamer = mebajaHamerSum > 30 ? mebajaHamerSum % 30 : mebajaHamerSum; 134 | 135 | let ninevehMonth = metqi > 14 ? 5 : 6; 136 | if (mebajaHamerSum > 30) ninevehMonth++; 137 | const ninevehDate = { year: ethiopianYear, month: ninevehMonth, day: mebajaHamer }; 138 | 139 | return { 140 | ameteAlem, 141 | meteneRabiet, 142 | medeb, 143 | wenber, 144 | abektie, 145 | metqi, 146 | bealeMetqiDate, 147 | bealeMetqiWeekday, 148 | mebajaHamer, 149 | ninevehDate, 150 | }; 151 | } -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { dayOfYear } from "../src/utils"; 2 | import { 3 | monthDayFromDayOfYear, 4 | isEthiopianLeapYear, 5 | isGregorianLeapYear, 6 | getEthiopianDaysInMonth 7 | } from "../src/utils"; 8 | 9 | 10 | describe('dayOfYear', () => { 11 | it('returns 1 for January 1st of a non-leap year', () => { 12 | expect(dayOfYear(2023, 1, 1)).toBe(1); 13 | }); 14 | 15 | it('returns 32 for February 1st of a non-leap year', () => { 16 | expect(dayOfYear(2023, 2, 1)).toBe(32); 17 | }); 18 | 19 | it('returns 59 for February 28th of a non-leap year', () => { 20 | expect(dayOfYear(2023, 2, 28)).toBe(59); 21 | }); 22 | 23 | it('returns 60 for March 1st of a non-leap year', () => { 24 | expect(dayOfYear(2023, 3, 1)).toBe(60); 25 | }); 26 | 27 | it('returns 60 for February 29th of a leap year', () => { 28 | expect(dayOfYear(2024, 2, 29)).toBe(60); 29 | }); 30 | 31 | it('returns 61 for March 1st of a leap year', () => { 32 | expect(dayOfYear(2024, 3, 1)).toBe(61); 33 | }); 34 | 35 | it('returns 365 for December 31st of a non-leap year', () => { 36 | expect(dayOfYear(2023, 12, 31)).toBe(365); 37 | }); 38 | 39 | it('returns 366 for December 31st of a leap year', () => { 40 | expect(dayOfYear(2024, 12, 31)).toBe(366); 41 | }); 42 | }); 43 | 44 | describe('monthDayFromDayOfYear', () => { 45 | it('returns January 1 for day 1 of a non-leap year', () => { 46 | expect(monthDayFromDayOfYear(2023, 1)).toEqual({ month: 1, day: 1 }); 47 | }); 48 | 49 | it('returns January 31 for day 31 of a non-leap year', () => { 50 | expect(monthDayFromDayOfYear(2023, 31)).toEqual({ month: 1, day: 31 }); 51 | }); 52 | 53 | it('returns February 1 for day 32 of a non-leap year', () => { 54 | expect(monthDayFromDayOfYear(2023, 32)).toEqual({ month: 2, day: 1 }); 55 | }); 56 | 57 | it('returns February 28 for day 59 of a non-leap year', () => { 58 | expect(monthDayFromDayOfYear(2023, 59)).toEqual({ month: 2, day: 28 }); 59 | }); 60 | 61 | it('returns March 1 for day 60 of a non-leap year', () => { 62 | expect(monthDayFromDayOfYear(2023, 60)).toEqual({ month: 3, day: 1 }); 63 | }); 64 | 65 | it('returns February 29 for day 60 of a leap year', () => { 66 | expect(monthDayFromDayOfYear(2024, 60)).toEqual({ month: 2, day: 29 }); 67 | }); 68 | 69 | it('returns March 1 for day 61 of a leap year', () => { 70 | expect(monthDayFromDayOfYear(2024, 61)).toEqual({ month: 3, day: 1 }); 71 | }); 72 | 73 | it('returns December 31 for day 365 of a non-leap year', () => { 74 | expect(monthDayFromDayOfYear(2023, 365)).toEqual({ month: 12, day: 31 }); 75 | }); 76 | 77 | it('returns December 31 for day 366 of a leap year', () => { 78 | expect(monthDayFromDayOfYear(2024, 366)).toEqual({ month: 12, day: 31 }); 79 | }); 80 | }); 81 | 82 | describe('isGregorianLeapYear', () => { 83 | 84 | it('returns true for years divisible by 4 but not by 100', () => { 85 | expect(isGregorianLeapYear(2024)).toBe(true); 86 | expect(isGregorianLeapYear(1996)).toBe(true); 87 | expect(isGregorianLeapYear(2008)).toBe(true); 88 | }); 89 | 90 | it('returns false for years not divisible by 4', () => { 91 | expect(isGregorianLeapYear(2023)).toBe(false); 92 | expect(isGregorianLeapYear(2019)).toBe(false); 93 | expect(isGregorianLeapYear(2101)).toBe(false); 94 | }); 95 | 96 | it('returns false for years divisible by 100 but not by 400', () => { 97 | expect(isGregorianLeapYear(1900)).toBe(false); 98 | expect(isGregorianLeapYear(2100)).toBe(false); 99 | expect(isGregorianLeapYear(1800)).toBe(false); 100 | }); 101 | 102 | it('returns true for years divisible by 400', () => { 103 | expect(isGregorianLeapYear(2000)).toBe(true); 104 | expect(isGregorianLeapYear(1600)).toBe(true); 105 | expect(isGregorianLeapYear(2400)).toBe(true); 106 | }); 107 | }); 108 | 109 | describe('isEthiopianLeapYear', () => { 110 | it('returns true for Ethiopian years where year % 4 === 3', () => { 111 | expect(isEthiopianLeapYear(2011)).toBe(true); 112 | expect(isEthiopianLeapYear(2015)).toBe(true); 113 | expect(isEthiopianLeapYear(2019)).toBe(true); 114 | expect(isEthiopianLeapYear(2003)).toBe(true); 115 | }); 116 | 117 | it('returns false for Ethiopian years where year % 4 !== 3', () => { 118 | expect(isEthiopianLeapYear(2010)).toBe(false); 119 | expect(isEthiopianLeapYear(2012)).toBe(false); 120 | expect(isEthiopianLeapYear(2013)).toBe(false); 121 | expect(isEthiopianLeapYear(2014)).toBe(false); 122 | expect(isEthiopianLeapYear(2020)).toBe(false); 123 | }); 124 | }); 125 | 126 | describe('getEthiopianDaysInMonth', () => { 127 | it('returns 30 for any month from 1 to 12', () => { 128 | for (let month = 1; month <= 12; month++) { 129 | expect(getEthiopianDaysInMonth(2010, month)).toBe(30); 130 | expect(getEthiopianDaysInMonth(2011, month)).toBe(30); 131 | expect(getEthiopianDaysInMonth(2012, month)).toBe(30); 132 | expect(getEthiopianDaysInMonth(2013, month)).toBe(30); 133 | } 134 | }); 135 | 136 | it('returns 6 for month 13 in a leap year', () => { 137 | // 2011, 2015, 2019 are leap years (year % 4 === 3) 138 | expect(getEthiopianDaysInMonth(2011, 13)).toBe(6); 139 | expect(getEthiopianDaysInMonth(2015, 13)).toBe(6); 140 | expect(getEthiopianDaysInMonth(2019, 13)).toBe(6); 141 | }); 142 | 143 | it('returns 5 for month 13 in a non-leap year', () => { 144 | // 2010, 2012, 2013, 2014, 2020 are not leap years 145 | expect(getEthiopianDaysInMonth(2010, 13)).toBe(5); 146 | expect(getEthiopianDaysInMonth(2012, 13)).toBe(5); 147 | expect(getEthiopianDaysInMonth(2013, 13)).toBe(5); 148 | expect(getEthiopianDaysInMonth(2014, 13)).toBe(5); 149 | expect(getEthiopianDaysInMonth(2020, 13)).toBe(5); 150 | }); 151 | }); 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests/conversions.test.js: -------------------------------------------------------------------------------- 1 | import { toEC, toGC } from "../src/conversions"; 2 | import { InvalidGregorianDateError } from "../src/errors/errorHandler.js"; 3 | 4 | describe('Ethiopian to Gregorian conversion', () => { 5 | 6 | test('Ethiopian to Gregorian: 2017-9-14 -> May 22, 2025', () => { 7 | const result = toGC(2017, 9, 14); 8 | expect(result).toEqual({ year: 2025, month: 5, day: 22 }); 9 | }); 10 | 11 | test('Ethiopian to Gregorian: Pagumē 5, 2016 (2016-13-5) -> September 10, 2024', () => { 12 | const result = toGC(2016, 13, 5); 13 | expect(result).toEqual({ year: 2024, month: 9, day: 10 }); 14 | }); 15 | 16 | test('Ethiopian to Gregorian Leap Year: 2011-13-6 (Pagumē 6, 2011) -> September 11, 2019', () => { 17 | const result = toGC(2011, 13, 6); 18 | expect(result).toEqual({ year: 2019, month: 9, day: 11 }); 19 | }); 20 | 21 | test('Ethiopian to Gregorian Leap Year: Pagumē 6, 2019 (2019-13-6) -> September 11, 2027', () => { 22 | const result = toGC(2019, 13, 6); 23 | expect(result).toEqual({ year: 2027, month: 9, day: 11 }); 24 | }); 25 | 26 | test('Ethiopian to Gregorian Leap Year: May 5, 2024 -> Miazia 27, 2016', () => { 27 | const result = toGC(2016, 8, 27); 28 | expect(result).toEqual({ year: 2024, month: 5, day: 5 }); 29 | }); 30 | 31 | test('Ethiopian to Gregorian: Meskerem 1, 2016 (2016-1-1) -> September 11, 2023', () => { 32 | const result = toGC(2016, 1, 1); 33 | expect(result).toEqual({ year: 2023, month: 9, day: 12 }); 34 | }); 35 | 36 | test('Ethiopian to Gregorian: Tahsas 30, 2015 (2015-4-30) -> January 8, 2023', () => { 37 | const result = toGC(2015, 4, 30); 38 | expect(result).toEqual({ year: 2023, month: 1, day: 8 }); 39 | }); 40 | 41 | test('Ethiopian to Gregorian: Pagume 1, 2011 (2011-13-1) -> September 6, 2019', () => { 42 | const result = toGC(2011, 13, 1); 43 | expect(result).toEqual({ year: 2019, month: 9, day: 6 }); 44 | }); 45 | 46 | test('Ethiopian to Gregorian: Meskerem 1, 1964 (1964-1-1) -> September 12, 1971', () => { 47 | const result = toGC(1964, 1, 1); 48 | expect(result).toEqual({ year: 1971, month: 9, day: 12 }); 49 | }); 50 | 51 | test('Ethiopian to Gregorian: Pagume 6, 2007 (leap Pagume) -> September 11, 2015', () => { 52 | const result = toGC(2007, 13, 6); 53 | expect(result).toEqual({ year: 2015, month: 9, day: 11 }); 54 | }); 55 | 56 | test('Ethiopian to Gregorian: Pagume 5, 2006 (non-leap Pagume) -> September 10, 2014', () => { 57 | const result = toGC(2006, 13, 5); 58 | expect(result).toEqual({ year: 2014, month: 9, day: 10 }); 59 | }); 60 | 61 | test('Ethiopian to Gregorian: End of Ethiopian year (Pagume 5, 2015) -> September 10, 2023', () => { 62 | const result = toGC(2015, 13, 5); 63 | expect(result).toEqual({ year: 2023, month: 9, day: 10 }); 64 | }); 65 | 66 | test('Ethiopian to Gregorian: Start of Ethiopian year (Meskerem 1, 2015) -> September 11, 2022', () => { 67 | const result = toGC(2015, 1, 1); 68 | expect(result).toEqual({ year: 2022, month: 9, day: 11 }); 69 | }); 70 | }); 71 | 72 | describe('Gregorian to Ethiopian conversion', () => { 73 | test('Gregorian to Ethiopian: May 22, 2025 -> 2017-9-14', () => { 74 | const result = toEC(2025, 5, 22); 75 | expect(result).toEqual({ year: 2017, month: 9, day: 14 }); 76 | }); 77 | 78 | test('Gregorian to Ethiopian Leap Year: February 29, 2020 -> Yekatit 22, 2012', () => { 79 | const result = toEC(2020, 2, 29); 80 | expect(result).toEqual({ year: 2012, month: 6, day: 21 }); 81 | }); 82 | 83 | test('Gregorian to Ethiopian Leap Year: May 5, 2024 -> Miazia 27, 2016', () => { 84 | const result = toEC(2024, 5, 5); 85 | expect(result).toEqual({ year: 2016, month: 8, day: 27 }); 86 | }); 87 | 88 | test('Gregorian to Ethiopian: September 10, 2024 -> 2016-13-5 (Pagumē 5, 2016)', () => { 89 | const result = toEC(2024, 9, 10); 90 | expect(result).toEqual({ year: 2016, month: 13, day: 5 }); 91 | }); 92 | 93 | test('Gregorian to Ethiopian Leap Year: September 11, 2019 -> 2011-13-6 (Pagumē 6, 2011)', () => { 94 | const result = toEC(2019, 9, 11); 95 | expect(result).toEqual({ year: 2011, month: 13, day: 6 }); 96 | }); 97 | 98 | test('Gregorian to Ethiopian Leap Year: September 11, 2027 -> 2019-13-6 (Pagumē 6, 2019)', () => { 99 | const result = toEC(2027, 9, 11); 100 | expect(result).toEqual({ year: 2019, month: 13, day: 6 }); 101 | }); 102 | 103 | test('Gregorian to Ethiopian: January 1, 2000 -> 1992-4-23', () => { 104 | const result = toEC(2000, 1, 1); 105 | expect(result).toEqual({ year: 1992, month: 4, day: 22 }); 106 | }); 107 | 108 | test('Gregorian to Ethiopian: Out of range year throws error', () => { 109 | expect(() => toEC(1800, 1, 1)).toThrow(InvalidGregorianDateError); 110 | expect(() => toEC(2200, 1, 1)).toThrow(InvalidGregorianDateError); 111 | }); 112 | 113 | test('Gregorian to Ethiopian: September 12, 1971 (base date) -> 1964-1-1', () => { 114 | const result = toEC(1971, 9, 12); 115 | expect(result).toEqual({ year: 1964, month: 1, day: 1 }); 116 | }); 117 | 118 | test('Gregorian to Ethiopian: December 31, 2100 (upper bound) -> valid Ethiopian date', () => { 119 | const result = toEC(2100, 12, 31); 120 | expect(result.year).toBeGreaterThanOrEqual(2092); 121 | expect(result.month).toBeGreaterThanOrEqual(4); 122 | expect(result.day).toBeGreaterThanOrEqual(20); 123 | }); 124 | 125 | test('Gregorian to Ethiopian: January 1, 1900 (lower bound) -> valid Ethiopian date', () => { 126 | const result = toEC(1900, 1, 1); 127 | expect(result.year).toBeLessThanOrEqual(1892); 128 | expect(result.month).toBeGreaterThanOrEqual(4); 129 | expect(result.day).toBeGreaterThanOrEqual(22); 130 | }); 131 | 132 | test('Gregorian to Ethiopian: End of Gregorian leap year (December 31, 2020)', () => { 133 | const result = toEC(2020, 12, 31); 134 | expect(result).toEqual({ year: 2013, month: 4, day: 22 }); 135 | }); 136 | 137 | test('Gregorian to Ethiopian: Start of Gregorian leap year (January 1, 2020)', () => { 138 | const result = toEC(2020, 1, 1); 139 | expect(result).toEqual({ year: 2012, month: 4, day: 22 }); 140 | }); 141 | 142 | test('Gregorian to Ethiopian: Last day of Ethiopian year (September 10, 2023)', () => { 143 | const result = toEC(2023, 9, 10); 144 | expect(result).toEqual({ year: 2015, month: 13, day: 5 }); 145 | }); 146 | 147 | test('Gregorian to Ethiopian: Leap Pagume (September 11, 2015)', () => { 148 | const result = toEC(2023, 9, 11); 149 | expect(result).toEqual({ year: 2015, month: 13, day: 6 }); 150 | }); 151 | }); -------------------------------------------------------------------------------- /src/fasting.js: -------------------------------------------------------------------------------- 1 | import { getBahireHasab } from './bahireHasab.js'; 2 | import { findHijriMonthRanges } from './holidays.js'; 3 | import { addDays, diffInDays } from './dayArithmetic.js'; 4 | import { fastingInfo, FastingKeys } from './constants.js'; 5 | import { getWeekday, getEthiopianDaysInMonth, validateNumericInputs, validateEthiopianDateObject } from './utils.js'; 6 | 7 | /** 8 | * Calculates the start and end dates of a specific fasting period for a given year. 9 | * @param {'ABIY_TSOME' | 'TSOME_HAWARYAT' | 'TSOME_NEBIYAT' | 'NINEVEH' | 'RAMADAN'} fastKey - The key for the fast. 10 | * @param {number} ethiopianYear - The Ethiopian year. 11 | * @returns {{start: object, end: object}|null} An object with start and end PLAIN date objects. 12 | */ 13 | export function getFastingPeriod(fastKey, ethiopianYear) { 14 | const bh = getBahireHasab(ethiopianYear); 15 | 16 | switch (fastKey) { 17 | case FastingKeys.ABIY_TSOME: { 18 | const start = bh.movableFeasts.abiyTsome?.ethiopian; 19 | const end = bh.movableFeasts.siklet?.ethiopian; 20 | if (start && end) { 21 | return { start, end }; 22 | } 23 | return null; 24 | } 25 | 26 | case FastingKeys.TSOME_HAWARYAT: { 27 | const start = bh.movableFeasts.tsomeHawaryat?.ethiopian; 28 | const end = { year: ethiopianYear, month: 11, day: 4 }; 29 | if (start) { 30 | return { start, end }; 31 | } 32 | return null; 33 | } 34 | 35 | case FastingKeys.NINEVEH: { 36 | const start = bh.movableFeasts.nineveh?.ethiopian; 37 | if (start) { 38 | const end = addDays(start, 2); 39 | return { start, end }; 40 | } 41 | return null; 42 | } 43 | 44 | case FastingKeys.TSOME_NEBIYAT: { 45 | const start = { year: ethiopianYear, month: 3, day: 15 }; 46 | const end = { year: ethiopianYear, month: 4, day: 28 }; 47 | return { start, end }; 48 | } 49 | 50 | case FastingKeys.FILSETA: { 51 | // Nehase 1 to Nehase 14 52 | const start = { year: ethiopianYear, month: 12, day: 1 }; 53 | const end = { year: ethiopianYear, month: 12, day: 14 }; 54 | return { start, end }; 55 | } 56 | 57 | case FastingKeys.RAMADAN: { 58 | const ranges = findHijriMonthRanges(ethiopianYear, 9); 59 | return ranges.length > 0 ? ranges[0] : null; 60 | } 61 | 62 | default: 63 | return null; 64 | } 65 | } 66 | 67 | 68 | /** 69 | * Returns fasting information (names, descriptions, period) for a given fast and year. 70 | * @param {'ABIY_TSOME'|'TSOME_HAWARYAT'|'TSOME_NEBIYAT'|'NINEVEH'|'RAMADAN'} fastKey 71 | * @param {number} ethiopianYear 72 | * @param {{lang?: 'amharic'|'english'}} options 73 | * @returns {{ key: string, name: string, description: string, period: { start: object, end: object } } | null} 74 | */ 75 | export function getFastingInfo(fastKey, ethiopianYear, options = {}) { 76 | validateNumericInputs('getFastingInfo', { ethiopianYear }); 77 | const { lang = 'amharic' } = options; 78 | const info = fastingInfo[fastKey]; 79 | if (!info) return null; 80 | 81 | const name = info?.name?.[lang] || info?.name?.english; 82 | const description = info?.description?.[lang] || info?.description?.english; 83 | // TSOME_DIHENET is a weekly fast (Wed/Fri) with an exception; it doesn't have a single contiguous period. 84 | if (fastKey === FastingKeys.TSOME_DIHENET) { 85 | return { 86 | key: fastKey, 87 | name, 88 | description, 89 | tags: info.tags, 90 | period: null, 91 | }; 92 | } 93 | 94 | const period = getFastingPeriod(fastKey, ethiopianYear); 95 | if (!period) return null; 96 | 97 | return { 98 | key: fastKey, 99 | name, 100 | description, 101 | tags: info.tags, 102 | period, 103 | }; 104 | } 105 | 106 | /** 107 | * Checks if a given Ethiopian date is an Orthodox weekly fasting day (Tsome Dihnet). 108 | * Rules: 109 | * - Fasting occurs every Wednesday and Friday. 110 | * - Exception: for the 50 days after Easter (Fasika) up to and including Pentecost (Paraclete), 111 | * Wednesdays and Fridays are NOT considered fasting days. 112 | * 113 | * @param {{year:number, month:number, day:number}} etDate - Ethiopian date object. 114 | * @returns {boolean} true if it's a fasting day, false otherwise. 115 | */ 116 | function isTsomeDihnetFastDay(etDate) { 117 | validateEthiopianDateObject(etDate, 'isTsomeDihnetFastDay', 'etDate'); 118 | 119 | const weekday = getWeekday(etDate); // 0=Sun ... 6=Sat 120 | const isWedOrFri = (weekday === 3 || weekday === 5); 121 | if (!isWedOrFri) return false; 122 | 123 | // Get Easter (Fasika) for the year and apply 50-day exception window 124 | const bh = getBahireHasab(etDate.year); 125 | const fasika = bh?.movableFeasts?.fasika?.ethiopian; 126 | const paraclete = bh?.movableFeasts?.paraclete?.ethiopian; 127 | if (!fasika || !paraclete) { 128 | // If for some reason we cannot compute the window, default to standard Wed/Fri fasting. 129 | return true; 130 | } 131 | 132 | const daysFromEaster = diffInDays(etDate, fasika); 133 | const inPentecostSeason = daysFromEaster >= 1 && diffInDays(etDate, paraclete) <= 0; 134 | return !inPentecostSeason; 135 | } 136 | 137 | // (no export) private helper only 138 | 139 | /** 140 | * Return an array of day numbers in the given Ethiopian month that belong to a fasting period. 141 | * For TSOME_DIHENET, it returns all Wednesdays and Fridays excluding the 50-day period after Easter (through Pentecost). 142 | * For fixed/range fasts, it returns the days intersecting the fast period. 143 | * 144 | * @param {string} fastKey - One of FastingKeys 145 | * @param {number} year - Ethiopian year 146 | * @param {number} month - Ethiopian month (1-13) 147 | * @returns {number[]} 148 | */ 149 | export function getFastingDays(fastKey, year, month) { 150 | validateNumericInputs('getFastingDays', { year, month }); 151 | const daysInMonth = getEthiopianDaysInMonth(year, month); 152 | 153 | if (fastKey === FastingKeys.TSOME_DIHENET) { 154 | const out = []; 155 | for (let d = 1; d <= daysInMonth; d++) { 156 | if (isTsomeDihnetFastDay({ year, month, day: d })) out.push(d); 157 | } 158 | return out; 159 | } 160 | 161 | // For other fasts: compute period and intersect with the month 162 | const period = getFastingPeriod(fastKey, year); 163 | if (!period) return []; 164 | 165 | const startYearMonth = { y: period.start.year, m: period.start.month }; 166 | const endYearMonth = { y: period.end.year, m: period.end.month }; 167 | 168 | // If the month is completely outside, return [] 169 | const before = (year < startYearMonth.y) || (year === startYearMonth.y && month < startYearMonth.m); 170 | const after = (year > endYearMonth.y) || (year === endYearMonth.y && month > endYearMonth.m); 171 | if (before || after) return []; 172 | 173 | const startDay = (year === period.start.year && month === period.start.month) ? period.start.day : 1; 174 | const endDay = (year === period.end.year && month === period.end.month) ? period.end.day : daysInMonth; 175 | const result = []; 176 | for (let d = startDay; d <= endDay; d++) result.push(d); 177 | return result; 178 | } 179 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { toGC, toEC } from './conversions.js'; 2 | import { monthNames } from './constants.js'; 3 | import { InvalidInputTypeError } from './errors/errorHandler.js'; 4 | 5 | // --- Validation Helpers --- 6 | 7 | /** 8 | * Validates that all provided date parts are numbers. 9 | * @param {string} funcName - The name of the function being validated. 10 | * @param {Object} dateParts - An object where keys are param names and values are the inputs. 11 | * @throws {InvalidInputTypeError} if any value is not a number. 12 | */ 13 | export function validateNumericInputs(funcName, dateParts) { 14 | for (const [name, value] of Object.entries(dateParts)) { 15 | if (typeof value !== 'number' || isNaN(value)) { 16 | throw new InvalidInputTypeError(funcName, name, 'number', value); 17 | } 18 | } 19 | } 20 | 21 | /** 22 | * Validates that the input is a valid Ethiopian date object. 23 | * @param {Object} dateObj - The object to validate. 24 | * @param {string} funcName - The name of the function being validated. 25 | * @param {string} paramName - The name of the parameter being validated. 26 | * @throws {InvalidInputTypeError} if the object is invalid. 27 | */ 28 | export function validateEthiopianDateObject(dateObj, funcName, paramName) { 29 | if (typeof dateObj !== 'object' || dateObj === null) { 30 | throw new InvalidInputTypeError(funcName, paramName, 'object', dateObj); 31 | } 32 | validateNumericInputs(funcName, { 33 | [`${paramName}.year`]: dateObj.year, 34 | [`${paramName}.month`]: dateObj.month, 35 | [`${paramName}.day`]: dateObj.day, 36 | }); 37 | } 38 | 39 | /** 40 | * Validates that the input is a valid Ethiopian time object. 41 | * @param {Object} timeObj - The object to validate. 42 | * @param {string} funcName - The name of the function being validated. 43 | * @param {string} paramName - The name of the parameter being validated. 44 | * @throws {InvalidInputTypeError} if the object is invalid. 45 | */ 46 | export function validateEthiopianTimeObject(timeObj, funcName, paramName) { 47 | if (typeof timeObj !== 'object' || timeObj === null) { 48 | throw new InvalidInputTypeError(funcName, paramName, 'object', timeObj); 49 | } 50 | if (typeof timeObj.period !== 'string' || (timeObj.period !== 'day' && timeObj.period !== 'night')) { 51 | throw new InvalidInputTypeError(funcName, `${paramName}.period`, "'day' or 'night'", timeObj.period); 52 | } 53 | validateNumericInputs(funcName, { 54 | [`${paramName}.hour`]: timeObj.hour, 55 | [`${paramName}.minute`]: timeObj.minute, 56 | }); 57 | } 58 | 59 | /** 60 | * Calculates the day of the year for a given date. 61 | * 62 | * @param {number} year - The full year (e.g., 2024). 63 | * @param {number} month - The month (1-based, January is 1, December is 12). 64 | * @param {number} day - The day of the month. 65 | * @returns {number} The day of the year (1-based). 66 | */ 67 | export function dayOfYear(year, month, day) { 68 | const monthLengths = [31, isGregorianLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 69 | let doy = 0; 70 | for (let i = 0; i < month - 1; i++) { 71 | doy += monthLengths[i]; 72 | } 73 | doy += day; 74 | return doy; 75 | } 76 | 77 | /** 78 | * Convert a day of year to Gregorian month and day. 79 | */ 80 | export function monthDayFromDayOfYear(year, dayOfYear) { 81 | const monthLengths = [31, isGregorianLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 82 | let month = 1; 83 | while (dayOfYear > monthLengths[month - 1]) { 84 | dayOfYear -= monthLengths[month - 1]; 85 | month++; 86 | } 87 | return { month, day: dayOfYear }; 88 | } 89 | 90 | /** 91 | * Checks if the given Gregorian year is a leap year. 92 | * 93 | * Gregorian leap years occur every 4 years, except centuries not divisible by 400. 94 | * For example: 2000 is a leap year, 1900 is not. 95 | * 96 | * @param {number} year - Gregorian calendar year (e.g., 2025) 97 | * @returns {boolean} - True if the year is a leap year, otherwise false. 98 | */ 99 | export function isGregorianLeapYear(year) { 100 | return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); 101 | } 102 | 103 | /** 104 | * Checks if the given Ethiopian year is a leap year. 105 | * 106 | * Ethiopian leap years occur every 4 years, when the year modulo 4 equals 3. 107 | * This means years like 2011, 2015, 2019 (in Ethiopian calendar) are leap years. 108 | * 109 | * @param {number} year - Ethiopian calendar year (e.g., 2011) 110 | * @returns {boolean} - True if the year is a leap year, otherwise false. 111 | */ 112 | export function isEthiopianLeapYear(year) { 113 | return year % 4 === 3; 114 | } 115 | 116 | /** 117 | * Returns the number of days in the given Ethiopian month and year. 118 | * @param {number} year - Ethiopian year 119 | * @param {number} month - Ethiopian month (1-13) 120 | * @returns {number} Number of days in the month 121 | */ 122 | export function getEthiopianDaysInMonth(year, month) { 123 | if (month === 13) { 124 | return isEthiopianLeapYear(year) ? 6 : 5; 125 | } 126 | return 30; 127 | } 128 | 129 | /** 130 | * Returns the weekday (0-6) for a given Ethiopian date. 131 | * 132 | * @param {Object} param0 - The Ethiopian date. 133 | * @param {number} param0.year - The Ethiopian year. 134 | * @param {number} param0.month - The Ethiopian month (1-13). 135 | * @param {number} param0.day - The Ethiopian day (1-30). 136 | * @returns {number} The day of the week (0 for Sunday, 6 for Saturday). 137 | */ 138 | export function getWeekday({ year, month, day }) { 139 | const g = toGC(year, month, day); 140 | return new Date(g.year, g.month - 1, g.day).getDay(); 141 | } 142 | 143 | /** 144 | * Checks if a given Ethiopian date is valid. 145 | * @param {number} year - Ethiopian year 146 | * @param {number} month - Ethiopian month (1-13) 147 | * @param {number} day - Ethiopian day (1-30 or 1-5/6) 148 | * @returns {boolean} - True if the date is valid, otherwise false. 149 | */ 150 | export function isValidEthiopianDate(year, month, day) { 151 | if (month < 1 || month > 13) { 152 | return false; 153 | } 154 | if (day < 1 || day > getEthiopianDaysInMonth(year, month)) { 155 | return false; 156 | } 157 | return true; 158 | } 159 | 160 | /** 161 | * Helper: Get Ethiopian New Year for a Gregorian year. 162 | * @param {number} gYear - The Gregorian year. 163 | * @returns {{gregorianYear: number, month: number, day: number}} 164 | * @throws {InvalidInputTypeError} If gYear is not a number. 165 | */ 166 | export function getEthiopianNewYearForGregorian(gYear) { 167 | validateNumericInputs('getEthiopianNewYearForGregorian', { gYear }); 168 | const prevGYear = gYear - 1; 169 | const newYearDay = isGregorianLeapYear(prevGYear) ? 12 : 11; 170 | return { 171 | gregorianYear: gYear, 172 | month: 9, 173 | day: newYearDay 174 | }; 175 | } 176 | 177 | /** 178 | * Returns the Gregorian date of the Ethiopian New Year for the given Ethiopian year. 179 | * 180 | * @param {number} ethiopianYear - Ethiopian calendar year. 181 | * @returns {{gregorianYear: number, month: number, day: number}} 182 | * @throws {InvalidInputTypeError} If ethiopianYear is not a number. 183 | */ 184 | export function getGregorianDateOfEthiopianNewYear(ethiopianYear) { 185 | validateNumericInputs('getGregorianDateOfEthiopianNewYear', { ethiopianYear }); 186 | const gregorianYear = ethiopianYear + 7; 187 | const newYearDay = isGregorianLeapYear(gregorianYear + 1) ? 12 : 11; 188 | return { gregorianYear, month: 9, day: newYearDay }; 189 | } -------------------------------------------------------------------------------- /src/conversions.js: -------------------------------------------------------------------------------- 1 | import { InvalidEthiopianDateError, InvalidGregorianDateError, InvalidInputTypeError } from './errors/errorHandler.js' 2 | import { getGregorianDateOfEthiopianNewYear } from './utils.js'; 3 | import { dayOfYear, monthDayFromDayOfYear, isGregorianLeapYear, isEthiopianLeapYear } from './utils.js'; 4 | 5 | /** 6 | * Validates that all provided date parts are numbers. 7 | * @param {string} funcName - The name of the function being validated. 8 | * @param {Object} dateParts - An object where keys are param names and values are the inputs. 9 | * @throws {InvalidInputTypeError} if any value is not a number. 10 | */ 11 | function validateNumericInputs(funcName, dateParts) { 12 | for (const [name, value] of Object.entries(dateParts)) { 13 | if (typeof value !== 'number') { 14 | throw new InvalidInputTypeError(funcName, name, 'number', value); 15 | } 16 | } 17 | } 18 | 19 | /** 20 | * Converts an Ethiopian date to its corresponding Gregorian date. 21 | * 22 | * @param {number} ethYear - The Ethiopian year. 23 | * @param {number} ethMonth - The Ethiopian month (1-13). 24 | * @param {number} ethDay - The Ethiopian day of the month. 25 | * @returns {{ year: number, month: number, day: number }} The equivalent Gregorian date. 26 | * @throws {InvalidInputTypeError} If any input is not a number. 27 | * @throws {InvalidEthiopianDateError} If the provided Ethiopian date is invalid. 28 | */ 29 | export function toGC(ethYear, ethMonth, ethDay) { 30 | // 1. Validate input types first 31 | validateNumericInputs('toGC', { ethYear, ethMonth, ethDay }); 32 | 33 | // 2. Validate date range 34 | if (ethMonth < 1 || ethMonth > 13) { 35 | throw new InvalidEthiopianDateError(ethYear, ethMonth, ethDay) 36 | } 37 | const maxDay = ethMonth === 13 ? (isEthiopianLeapYear(ethYear) ? 6 : 5) : 30 38 | if (ethDay < 1 || ethDay > maxDay) { 39 | throw new InvalidEthiopianDateError(ethYear, ethMonth, ethDay) 40 | } 41 | 42 | // 3. Perform conversion 43 | const newYear = getGregorianDateOfEthiopianNewYear(ethYear) 44 | const daysSinceNewYear = (ethMonth - 1) * 30 + ethDay - 1 45 | const newYearDOY = dayOfYear(newYear.gregorianYear, newYear.month, newYear.day) 46 | let gregorianDOY = newYearDOY + daysSinceNewYear 47 | let gregorianYear = newYear.gregorianYear 48 | const yearLength = isGregorianLeapYear(gregorianYear) ? 366 : 365 49 | 50 | if (gregorianDOY > yearLength) { 51 | gregorianDOY -= yearLength 52 | gregorianYear += 1 53 | } 54 | 55 | const { month, day } = monthDayFromDayOfYear(gregorianYear, gregorianDOY) 56 | return { year: gregorianYear, month, day } 57 | } 58 | 59 | 60 | /** 61 | * Converts a Gregorian date to the Ethiopian calendar (EC) date. 62 | * 63 | * @param {number} gYear - The Gregorian year (e.g., 2024). 64 | * @param {number} gMonth - The Gregorian month (1-12). 65 | * @param {number} gDay - The Gregorian day of the month (1-31). 66 | * @returns {{ year: number, month: number, day: number }} The corresponding Ethiopian calendar date. 67 | * @throws {InvalidInputTypeError} If any input is not a number. 68 | * @throws {InvalidGregorianDateError} If the input date is invalid or out of supported range. 69 | */ 70 | export function toEC(gYear, gMonth, gDay) { 71 | // 1. Validate input types first 72 | validateNumericInputs('toEC', { gYear, gMonth, gDay }); 73 | 74 | // 2. Validate date range and validity 75 | const isValidDate = (y, m, d) => { 76 | const date = new Date(Date.UTC(y, m - 1, d)) 77 | return ( 78 | date.getUTCFullYear() === y && 79 | date.getUTCMonth() === m - 1 && 80 | date.getUTCDate() === d 81 | ) 82 | } 83 | 84 | const inputDate = new Date(Date.UTC(gYear, gMonth - 1, gDay)) 85 | const minDate = new Date(Date.UTC(1900, 0, 1)) 86 | const maxDate = new Date(Date.UTC(2100, 11, 31)) 87 | 88 | if (!isValidDate(gYear, gMonth, gDay) || inputDate < minDate || inputDate > maxDate) { 89 | throw new InvalidGregorianDateError(gYear, gMonth, gDay) 90 | } 91 | 92 | // 3. Perform conversion 93 | const oneDay = 86400000 94 | const oneYear = 365 * oneDay 95 | const fourYears = 1461 * oneDay 96 | const baseDate = new Date(Date.UTC(1971, 8, 12)) 97 | const diff = inputDate.getTime() - baseDate.getTime() 98 | const fourYearCycles = Math.floor(diff / fourYears) 99 | let remainingYears = Math.floor((diff - fourYearCycles * fourYears) / oneYear) 100 | 101 | if (remainingYears === 4) remainingYears = 3 102 | 103 | const remainingMonths = Math.floor( 104 | (diff - fourYearCycles * fourYears - remainingYears * oneYear) / (30 * oneDay) 105 | ) 106 | 107 | const remainingDays = Math.floor( 108 | (diff - fourYearCycles * fourYears - remainingYears * oneYear - remainingMonths * 30 * oneDay) / oneDay 109 | ) 110 | 111 | const ethYear = 1964 + fourYearCycles * 4 + remainingYears 112 | const month = remainingMonths + 1 113 | const day = remainingDays + 1 114 | 115 | return { year: ethYear, month, day } 116 | } 117 | 118 | /** 119 | * Converts an Ethiopian date to a Gregorian Calendar JavaScript Date object (UTC). 120 | * 121 | * @param {number} ethYear - The Ethiopian year. 122 | * @param {number} ethMonth - The Ethiopian month (1-based). 123 | * @param {number} ethDay - The Ethiopian day. 124 | * @returns {Date} A JavaScript Date object representing the equivalent Gregorian date in UTC. 125 | */ 126 | export function toGCDate(ethYear, ethMonth, ethDay) { 127 | const { year, month, day } = toGC(ethYear, ethMonth, ethDay); 128 | return new Date(Date.UTC(year, month - 1, day)); 129 | } 130 | 131 | /** 132 | * Converts a JavaScript Date object to the Ethiopian Calendar (EC) date representation. 133 | * 134 | * @param {Date} dateObj - The JavaScript Date object to convert. 135 | * @returns {*} The Ethiopian Calendar date, as returned by the `toEC` function. 136 | */ 137 | export function fromDateToEC(dateObj) { 138 | return toEC( 139 | dateObj.getFullYear(), 140 | dateObj.getMonth() + 1, 141 | dateObj.getDate() 142 | ); 143 | } 144 | 145 | // muslim conversions 146 | 147 | export const islamicFormatter = new Intl.DateTimeFormat('en-TN-u-ca-islamic', { 148 | day: 'numeric', 149 | month: 'numeric', 150 | year: 'numeric', 151 | }); 152 | 153 | /** 154 | * Get Hijri year from a Gregorian date 155 | * @param {Date} date 156 | * @returns {number} hijri year 157 | */ 158 | export function getHijriYear(date) { 159 | const parts = islamicFormatter.formatToParts(date); 160 | let hYear = null; 161 | parts.forEach(({ type, value }) => { 162 | if (type === 'year') hYear = parseInt(value, 10); 163 | }); 164 | return hYear; 165 | } 166 | 167 | const hijriToGregorianCache = new Map(); 168 | 169 | /** 170 | * Converts a Hijri date to the corresponding Gregorian date within a given Gregorian year. 171 | * 172 | * @param {number} hYear - Hijri year (e.g., 1445) 173 | * @param {number} hMonth - Hijri month (1–12) 174 | * @param {number} hDay - Hijri day (1–30) 175 | * @param {number} gregorianYear - Target Gregorian year to restrict the search range 176 | * @returns {Date|null} Gregorian Date object or null if not found 177 | */ 178 | export function hijriToGregorian(hYear, hMonth, hDay, gregorianYear) { 179 | const cacheKey = `${hYear}-${hMonth}-${hDay}-${gregorianYear}`; 180 | if (hijriToGregorianCache.has(cacheKey)) { 181 | return hijriToGregorianCache.get(cacheKey); 182 | } 183 | 184 | const baseDate = new Date(gregorianYear - 1, 0, 1); 185 | for (let offset = 0; offset <= 730; offset++) { 186 | const testDate = new Date(baseDate); 187 | testDate.setDate(testDate.getDate() + offset); 188 | 189 | const parts = islamicFormatter.formatToParts(testDate); 190 | const hijriParts = {}; 191 | parts.forEach(({ type, value }) => { 192 | if (type !== 'literal') hijriParts[type] = parseInt(value, 10); 193 | }); 194 | 195 | if ( 196 | hijriParts.year === hYear && 197 | hijriParts.month === hMonth && 198 | hijriParts.day === hDay && 199 | testDate.getFullYear() === gregorianYear 200 | ) { 201 | hijriToGregorianCache.set(cacheKey, testDate); 202 | return testDate; 203 | } 204 | } 205 | 206 | hijriToGregorianCache.set(cacheKey, null); 207 | return null; 208 | } -------------------------------------------------------------------------------- /src/dayArithmetic.js: -------------------------------------------------------------------------------- 1 | import { 2 | getEthiopianDaysInMonth, 3 | isEthiopianLeapYear, 4 | validateNumericInputs, 5 | validateEthiopianDateObject 6 | } from './utils.js'; 7 | 8 | 9 | /** 10 | * Adds a specified number of days to an Ethiopian date. 11 | * 12 | * @param {Object} ethiopian - The Ethiopian date object { year, month, day }. 13 | * @param {number} days - The number of days to add. 14 | * @returns {Object} The resulting Ethiopian date. 15 | * @throws {InvalidInputTypeError} If inputs are not of the correct type. 16 | */ 17 | export function addDays(ethiopian, days) { 18 | validateEthiopianDateObject(ethiopian, 'addDays', 'ethiopian'); 19 | validateNumericInputs('addDays', { days }); 20 | 21 | let { year, month, day } = ethiopian; 22 | day += days; 23 | 24 | // Handle positive days (moving forward) 25 | while (day > getEthiopianDaysInMonth(year, month)) { 26 | day -= getEthiopianDaysInMonth(year, month); 27 | month += 1; 28 | 29 | if (month > 13) { 30 | month = 1; 31 | year += 1; 32 | } 33 | } 34 | 35 | // Handle negative days (moving backward) 36 | while (day <= 0) { 37 | month -= 1; 38 | if (month < 1) { 39 | month = 13; 40 | year -= 1; 41 | } 42 | day += getEthiopianDaysInMonth(year, month); 43 | } 44 | 45 | return { year, month, day }; 46 | } 47 | 48 | /** 49 | * Adds a specified number of months to an Ethiopian date. 50 | * 51 | * @param {Object} ethiopian - The Ethiopian date object { year, month, day }. 52 | * @param {number} months - The number of months to add. 53 | * @returns {Object} The resulting Ethiopian date. 54 | * @throws {InvalidInputTypeError} If inputs are not of the correct type. 55 | */ 56 | export function addMonths(ethiopian, months) { 57 | validateEthiopianDateObject(ethiopian, 'addMonths', 'ethiopian'); 58 | validateNumericInputs('addMonths', { months }); 59 | 60 | let { year, month, day } = ethiopian; 61 | let totalMonths = month + months; 62 | 63 | year += Math.floor((totalMonths - 1) / 13); 64 | month = ((totalMonths - 1) % 13 + 13) % 13 + 1; 65 | 66 | const daysInTargetMonth = getEthiopianDaysInMonth(year, month); 67 | if (day > daysInTargetMonth) { 68 | day = daysInTargetMonth; 69 | } 70 | 71 | return { year, month, day }; 72 | } 73 | 74 | /** 75 | * Adds a specified number of years to an Ethiopian date. 76 | * 77 | * @param {Object} ethiopian - The Ethiopian date object { year, month, day }. 78 | * @param {number} years - The number of years to add. 79 | * @returns {Object} The resulting Ethiopian date. 80 | * @throws {InvalidInputTypeError} If inputs are not of the correct type. 81 | */ 82 | export function addYears(ethiopian, years) { 83 | validateEthiopianDateObject(ethiopian, 'addYears', 'ethiopian'); 84 | validateNumericInputs('addYears', { years }); 85 | 86 | let { year, month, day } = ethiopian; 87 | year += years; 88 | 89 | if (month === 13 && day === 6 && !isEthiopianLeapYear(year)) { 90 | day = 5; 91 | } 92 | 93 | return { year, month, day }; 94 | } 95 | 96 | /** 97 | * Calculates the difference in days between two Ethiopian dates. 98 | * 99 | * @param {Object} a - The first Ethiopian date object. 100 | * @param {Object} b - The second Ethiopian date object. 101 | * @returns {number} The difference in days. 102 | * @throws {InvalidInputTypeError} If inputs are not valid date objects. 103 | */ 104 | export function diffInDays(a, b) { 105 | validateEthiopianDateObject(a, 'diffInDays', 'a'); 106 | validateEthiopianDateObject(b, 'diffInDays', 'b'); 107 | 108 | const totalDays = (eth) => { 109 | let days = 0; 110 | for (let y = 1; y < eth.year; y++) { 111 | days += isEthiopianLeapYear(y) ? 366 : 365; 112 | } 113 | for (let m = 1; m < eth.month; m++) { 114 | days += getEthiopianDaysInMonth(eth.year, m); 115 | } 116 | days += eth.day; 117 | return days; 118 | }; 119 | 120 | return totalDays(a) - totalDays(b); 121 | } 122 | 123 | /** 124 | * Calculates the difference in months between two Ethiopian dates. 125 | * 126 | * @param {Object} a - The first Ethiopian date object. 127 | * @param {Object} b - The second Ethiopian date object. 128 | * @returns {number} The difference in months. 129 | * @throws {InvalidInputTypeError} If inputs are not valid date objects. 130 | */ 131 | export function diffInMonths(a, b) { 132 | validateEthiopianDateObject(a, 'diffInMonths', 'a'); 133 | validateEthiopianDateObject(b, 'diffInMonths', 'b'); 134 | 135 | const totalMonthsA = a.year * 13 + (a.month - 1); 136 | const totalMonthsB = b.year * 13 + (b.month - 1); 137 | let diff = totalMonthsA - totalMonthsB; 138 | 139 | if (a.day < b.day) { 140 | diff -= 1; 141 | } 142 | 143 | return diff; 144 | } 145 | 146 | /** 147 | * Calculates the difference in years between two Ethiopian dates. 148 | * 149 | * @param {Object} a - The first Ethiopian date object. 150 | * @param {Object} b - The second Ethiopian date object. 151 | * @returns {number} The difference in years. 152 | * @throws {InvalidInputTypeError} If inputs are not valid date objects. 153 | */ 154 | export function diffInYears(a, b) { 155 | validateEthiopianDateObject(a, 'diffInYears', 'a'); 156 | validateEthiopianDateObject(b, 'diffInYears', 'b'); 157 | 158 | const isAfter = (a.year > b.year) || 159 | (a.year === b.year && a.month > b.month) || 160 | (a.year === b.year && a.month === b.month && a.day >= b.day); 161 | 162 | const [later, earlier] = isAfter ? [a, b] : [b, a]; 163 | let diff = later.year - earlier.year; 164 | 165 | if (later.month < earlier.month || (later.month === earlier.month && later.day < earlier.day)) { 166 | diff--; 167 | } 168 | 169 | const finalDiff = isAfter ? diff : -diff; 170 | 171 | // Coerce -0 to 0 to ensure strict equality passes in tests. 172 | if (finalDiff === 0) { 173 | return 0; 174 | } 175 | 176 | return finalDiff; 177 | } 178 | 179 | /** 180 | * Calculates a human-friendly breakdown between two Ethiopian dates. 181 | * Iteratively accumulates years, then months, then days to avoid off-by-one issues. 182 | * 183 | * @param {Object} a - First Ethiopian date { year, month, day }. 184 | * @param {Object} b - Second Ethiopian date { year, month, day }. 185 | * @param {Object} [options] 186 | * @param {Array<'years'|'months'|'days'>} [options.units=['years','months','days']] - Units to include, in order. 187 | * @returns {{ sign: 1|-1, years?: number, months?: number, days?: number, totalDays: number }} 188 | */ 189 | export function diffBreakdown(a, b, options = {}) { 190 | validateEthiopianDateObject(a, 'diffBreakdown', 'a'); 191 | validateEthiopianDateObject(b, 'diffBreakdown', 'b'); 192 | 193 | const { units = ['years', 'months', 'days'] } = options; 194 | const totalDaysDiff = diffInDays(a, b); // positive if a after b 195 | 196 | const sign = totalDaysDiff === 0 ? 1 : (totalDaysDiff > 0 ? 1 : -1); 197 | const later = sign >= 0 ? a : b; 198 | const earlier = sign >= 0 ? b : a; 199 | 200 | let cursor = { ...earlier }; 201 | const result = { sign, totalDays: Math.abs(totalDaysDiff) }; 202 | 203 | if (units.includes('years')) { 204 | let years = 0; 205 | while (true) { 206 | const next = addYears(cursor, 1); 207 | if (diffInDays(later, next) >= 0) { 208 | years += 1; 209 | cursor = next; 210 | } else { 211 | break; 212 | } 213 | } 214 | result.years = years; 215 | } 216 | 217 | if (units.includes('months')) { 218 | let months = 0; 219 | while (true) { 220 | const next = addMonths(cursor, 1); 221 | if (diffInDays(later, next) >= 0) { 222 | months += 1; 223 | cursor = next; 224 | } else { 225 | break; 226 | } 227 | } 228 | result.months = months; 229 | } 230 | 231 | if (units.includes('days')) { 232 | result.days = Math.abs(diffInDays(later, cursor)); 233 | } 234 | 235 | return result; 236 | } 237 | -------------------------------------------------------------------------------- /tests/fasting.test.js: -------------------------------------------------------------------------------- 1 | import { getFastingPeriod, getFastingInfo, getFastingDays } from '../src/fasting.js'; 2 | import { FastingKeys } from '../src/constants.js'; 3 | import { getBahireHasab } from '../src/bahireHasab.js'; 4 | import { addDays } from '../src/dayArithmetic.js'; 5 | import { getWeekday, getEthiopianDaysInMonth } from '../src/utils.js'; 6 | 7 | // Mock dependencies if they are not available in the test environment 8 | // For this example, we assume the underlying functions are correct. 9 | 10 | describe('getFastingPeriod', () => { 11 | 12 | describe('Christian Fasts for 2016 E.C.', () => { 13 | const year = 2016; 14 | 15 | test('should return the correct start and end dates for The Great Lent (Abiy Tsome)', () => { 16 | const period = getFastingPeriod(FastingKeys.ABIY_TSOME, year); 17 | // VERIFIED: In 2016, Abiy Tsome starts on Megabit 2 and ends on Siklet (Miazia 25) 18 | expect(period.start).toEqual({ year: 2016, month: 7, day: 2 }); 19 | expect(period.end).toEqual({ year: 2016, month: 8, day: 25 }); 20 | }); 21 | 22 | test('should return the correct start and end dates for Fast of the Apostles (Tsome Hawaryat)', () => { 23 | const period = getFastingPeriod(FastingKeys.TSOME_HAWARYAT, year); 24 | // VERIFIED: In 2016, Paraclete is Sene 17, so Tsome Hawaryat starts Sene 18. It ends on Hamle 4. 25 | expect(period.end).toEqual({ year: 2016, month: 11, day: 4 }); 26 | }); 27 | 28 | test('should return the correct start and end dates for Fast of Nineveh', () => { 29 | const period = getFastingPeriod(FastingKeys.NINEVEH, year); 30 | // VERIFIED: In 2016, Nineveh starts on Yekatit 18 and lasts 3 days. 31 | expect(period.start).toEqual({ year: 2016, month: 6, day: 18 }); 32 | expect(period.end).toEqual({ year: 2016, month: 6, day: 20 }); 33 | }); 34 | 35 | test('should return the correct start and end dates for Fast of the Prophets (Tsome Nebiyat)', () => { 36 | const period = getFastingPeriod(FastingKeys.TSOME_NEBIYAT, year); 37 | // This is a fixed fast from Hidar 15 to Tahsas 28. This test was already correct. 38 | expect(period.start).toEqual({ year: 2016, month: 3, day: 15 }); 39 | expect(period.end).toEqual({ year: 2016, month: 4, day: 28 }); 40 | }); 41 | }); 42 | 43 | describe('Muslim Fasts for 2016 E.C.', () => { 44 | const year = 2016; 45 | 46 | test('should return the correct start and end dates for Ramadan', () => { 47 | const period = getFastingPeriod(FastingKeys.RAMADAN, year); 48 | // VERIFIED: For 2016 E.C., Ramadan 1445 A.H. runs from Megabit 2 to Miazia 1. 49 | expect(period.start).toEqual({ year: 2016, month: 7, day: 2 }); 50 | }); 51 | }); 52 | 53 | describe('Error and Edge Case Handling', () => { 54 | test('should return null for an unknown fast key', () => { 55 | const period = getFastingPeriod('UNKNOWN_FAST_KEY', 2016); 56 | expect(period).toBeNull(); 57 | }); 58 | 59 | test('should return a valid period for a future year', () => { 60 | // This confirms the calculation logic doesn't crash on different inputs. 61 | const period = getFastingPeriod(FastingKeys.ABIY_TSOME, 2020); 62 | expect(period).toBeDefined(); 63 | expect(period.start).toBeDefined(); 64 | expect(period.end).toBeDefined(); 65 | }); 66 | }); 67 | }); 68 | 69 | describe('getFastingInfo', () => { 70 | const year = 2016; 71 | 72 | test('returns multilingual info and period for Abiy Tsome', () => { 73 | const infoAm = getFastingInfo(FastingKeys.ABIY_TSOME, year, { lang: 'amharic' }); 74 | expect(infoAm).toBeTruthy(); 75 | expect(infoAm.key).toBe(FastingKeys.ABIY_TSOME); 76 | expect(infoAm.name).toBe('ዐቢይ ጾም (ሁዳዴ)'); 77 | expect(infoAm.description).toBeDefined(); 78 | expect(infoAm.period.start).toEqual({ year: 2016, month: 7, day: 2 }); 79 | 80 | const infoEn = getFastingInfo(FastingKeys.ABIY_TSOME, year, { lang: 'english' }); 81 | expect(infoEn.name).toMatch(/Great Lent/i); 82 | expect(infoEn.period.end).toEqual({ year: 2016, month: 8, day: 25 }); 83 | }); 84 | 85 | test('returns info for Nineveh with 3-day period', () => { 86 | const info = getFastingInfo(FastingKeys.NINEVEH, year); 87 | expect(info.name).toBe('ጾመ ነነዌ'); 88 | expect(info.period.start).toEqual({ year: 2016, month: 6, day: 18 }); 89 | expect(info.period.end).toEqual({ year: 2016, month: 6, day: 20 }); 90 | }); 91 | 92 | test('returns info for Ramadan including tags', () => { 93 | const info = getFastingInfo(FastingKeys.RAMADAN, year, { lang: 'english' }); 94 | expect(info.name).toBe('Ramadan'); 95 | expect(Array.isArray(info.tags)).toBe(true); 96 | expect(info.period.start).toEqual({ year: 2016, month: 7, day: 2 }); 97 | }); 98 | 99 | test('returns info for Filseta (fixed Nehase 1-14)', () => { 100 | const info = getFastingInfo(FastingKeys.FILSETA, year, { lang: 'amharic' }); 101 | expect(info).toBeTruthy(); 102 | expect(info.key).toBe(FastingKeys.FILSETA); 103 | expect(info.name).toBe('ፍልሰታ'); 104 | expect(info.period.start).toEqual({ year: 2016, month: 12, day: 1 }); 105 | expect(info.period.end).toEqual({ year: 2016, month: 12, day: 14 }); 106 | }); 107 | }); 108 | 109 | describe('Orthodox Weekly Fasting (Tsome Dihnet)', () => { 110 | test('Wednesdays and Fridays are fasting days outside the 50 days after Easter', () => { 111 | const year = 2016; 112 | // Choose a month well before Easter season: Hidar (month 3) 113 | const month = 3; 114 | const daysInMonth = getEthiopianDaysInMonth(year, month); 115 | let foundWed = false; 116 | let foundFri = false; 117 | const days = getFastingDays('TSOME_DIHENET', year, month); 118 | for (let day = 1; day <= daysInMonth; day++) { 119 | const date = { year, month, day }; 120 | const wd = getWeekday(date); 121 | if (wd === 3) { // Wednesday 122 | foundWed = true; 123 | expect(days.includes(day)).toBe(true); 124 | } 125 | if (wd === 5) { // Friday 126 | foundFri = true; 127 | expect(days.includes(day)).toBe(true); 128 | } 129 | } 130 | expect(foundWed || foundFri).toBe(true); 131 | }); 132 | 133 | test('No fasting on Wednesdays/Fridays during the 50 days after Easter (until Pentecost)', () => { 134 | const year = 2016; 135 | const bh = getBahireHasab(year); 136 | const easter = bh.movableFeasts.fasika.ethiopian; 137 | const pentecost = bh.movableFeasts.paraclete.ethiopian; 138 | 139 | // Scan the full window starting the day after Easter through Pentecost inclusive 140 | let d = addDays(easter, 1); 141 | while (true) { 142 | const wd = getWeekday(d); 143 | if (wd === 3 || wd === 5) { 144 | const list = getFastingDays('TSOME_DIHENET', d.year, d.month); 145 | expect(list.includes(d.day)).toBe(false); 146 | } 147 | if (d.year === pentecost.year && d.month === pentecost.month && d.day === pentecost.day) break; 148 | d = addDays(d, 1); 149 | } 150 | }); 151 | 152 | test('Fasting resumes on Wed/Fri after Pentecost in the same year', () => { 153 | const year = 2016; 154 | const bh = getBahireHasab(year); 155 | const pentecost = bh.movableFeasts.paraclete.ethiopian; 156 | // Search within a few weeks after Pentecost for a Wed or Fri marked as fasting 157 | let found = false; 158 | for (let i = 1; i <= 21; i++) { 159 | const d = addDays(pentecost, i); 160 | const wd = getWeekday(d); 161 | if (wd === 3 || wd === 5) { 162 | const list = getFastingDays('TSOME_DIHENET', d.year, d.month); 163 | expect(list.includes(d.day)).toBe(true); 164 | found = true; 165 | break; 166 | } 167 | } 168 | expect(found).toBe(true); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /src/MonthGrid.js: -------------------------------------------------------------------------------- 1 | import { Kenat } from './Kenat.js'; 2 | import { getHolidaysInMonth } from './holidays.js'; 3 | import { toGeez } from './geezConverter.js'; 4 | import { orthodoxMonthlydays } from './nigs.js'; 5 | import { daysOfWeek, monthNames, HolidayTags, holidayInfo } from './constants.js'; 6 | import { getWeekday, validateNumericInputs } from './utils.js'; 7 | import { InvalidGridConfigError } from './errors/errorHandler.js'; 8 | 9 | export class MonthGrid { 10 | constructor(config = {}) { 11 | this._validateConfig(config); 12 | const current = Kenat.now().getEthiopian(); 13 | this.year = config.year ?? current.year; 14 | this.month = config.month ?? current.month; 15 | this.weekStart = config.weekStart ?? 1; 16 | this.useGeez = config.useGeez ?? false; 17 | this.weekdayLang = config.weekdayLang ?? 'amharic'; 18 | this.holidayFilter = config.holidayFilter ?? null; 19 | this.mode = config.mode ?? null; 20 | this.showAllSaints = config.showAllSaints ?? false; 21 | } 22 | 23 | _validateConfig(config) { 24 | const { year, month, weekStart, weekdayLang } = config; 25 | if ((year !== undefined && month === undefined) || (year === undefined && month !== undefined)) { 26 | throw new InvalidGridConfigError('If providing year or month, both must be provided.'); 27 | } 28 | if (year !== undefined) validateNumericInputs('MonthGrid.constructor', { year }); 29 | if (month !== undefined) validateNumericInputs('MonthGrid.constructor', { month }); 30 | if (weekStart !== undefined) { 31 | validateNumericInputs('MonthGrid.constructor', { weekStart }); 32 | if (weekStart < 0 || weekStart > 6) { 33 | throw new InvalidGridConfigError(`Invalid weekStart value: ${weekStart}. Must be between 0 and 6.`); 34 | } 35 | } 36 | if (weekdayLang !== undefined) { 37 | if (typeof weekdayLang !== 'string' || !Object.keys(daysOfWeek).includes(weekdayLang)) { 38 | throw new InvalidGridConfigError(`Invalid weekdayLang: "${weekdayLang}". Must be one of [${Object.keys(daysOfWeek).join(', ')}].`); 39 | } 40 | } 41 | } 42 | 43 | static create(config = {}) { 44 | const instance = new MonthGrid(config); 45 | return instance.generate(); 46 | } 47 | 48 | generate() { 49 | const rawDays = this._getRawDays(); 50 | const holidays = this._getFilteredHolidays(); 51 | const saints = this._getSaintsMap(); 52 | const paddedDays = this._mergeDays(rawDays, holidays, saints); 53 | const headers = this._getWeekdayHeaders(); 54 | const monthName = this._getLocalizedMonthName(); 55 | const yearLabel = this._getLocalizedYear(); 56 | 57 | return { 58 | headers, 59 | days: paddedDays, 60 | year: yearLabel, 61 | month: this.month, 62 | monthName, 63 | up: () => this.up().generate(), 64 | down: () => this.down().generate() 65 | }; 66 | } 67 | 68 | _getRawDays() { 69 | const base = new Kenat(`${this.year}/${this.month}/1`); 70 | return base.getMonthCalendar(this.year, this.month, this.useGeez); 71 | } 72 | 73 | _getFilteredHolidays() { 74 | let filter = this.holidayFilter; 75 | if (this.mode === 'christian') filter = [HolidayTags.CHRISTIAN]; 76 | if (this.mode === 'muslim') filter = [HolidayTags.MUSLIM]; 77 | if (this.mode === 'public') filter = [HolidayTags.PUBLIC]; 78 | return getHolidaysInMonth(this.year, this.month, { 79 | lang: this.weekdayLang, 80 | filter 81 | }); 82 | } 83 | 84 | _getSaintsMap() { 85 | if (this.mode !== 'christian') return {}; 86 | const map = {}; 87 | 88 | Object.entries(orthodoxMonthlydays).forEach(([saintKey, saint]) => { 89 | if (saint.events) { 90 | // This is a nested saint object with multiple events 91 | const nigsEvent = saint.events.find(event => 92 | Array.isArray(event.negs) ? event.negs.includes(this.month) : event.negs === this.month 93 | ); 94 | 95 | if (nigsEvent) { 96 | // It's a major feast ("Nigs") month, so show the specific event 97 | const day = saint.recuringDate; 98 | if (!map[day]) map[day] = []; 99 | map[day].push({ 100 | key: nigsEvent.key, 101 | name: saint.name[this.weekdayLang] || saint.name.english, 102 | description: nigsEvent.description[this.weekdayLang] || nigsEvent.description.english, 103 | isNigs: true, 104 | tags: [HolidayTags.RELIGIOUS, HolidayTags.CHRISTIAN, 'NIGS'] 105 | }); 106 | } else if (this.showAllSaints && saint.defaultDescription) { 107 | // It's NOT a major feast month, but the user wants to see all saints. Show the generic commemoration. 108 | const day = saint.recuringDate; 109 | if (!map[day]) map[day] = []; 110 | map[day].push({ 111 | key: saintKey, // Use the parent key for the generic event 112 | name: saint.name[this.weekdayLang] || saint.name.english, 113 | description: saint.defaultDescription[this.weekdayLang] || saint.defaultDescription.english, 114 | isNigs: false, 115 | tags: [HolidayTags.RELIGIOUS, HolidayTags.CHRISTIAN, 'SAINT_DAY'] 116 | }); 117 | } 118 | } else { 119 | // This is a flat (single-event) saint object 120 | const isNigs = Array.isArray(saint.negs) ? saint.negs.includes(this.month) : saint.negs === this.month; 121 | if (isNigs || this.showAllSaints) { 122 | const day = saint.recuringDate; 123 | if (!map[day]) map[day] = []; 124 | map[day].push({ 125 | key: saint.key, 126 | name: saint.name[this.weekdayLang] || saint.name.english, 127 | description: saint.description[this.weekdayLang] || saint.description.english, 128 | isNigs, 129 | tags: [HolidayTags.RELIGIOUS, HolidayTags.CHRISTIAN, isNigs ? 'NIGS' : 'SAINT_DAY'] 130 | }); 131 | } 132 | } 133 | }); 134 | return map; 135 | } 136 | 137 | _mergeDays(rawDays, holidaysList, saintsMap) { 138 | const today = Kenat.now().getEthiopian(); 139 | const labels = daysOfWeek[this.weekdayLang] || daysOfWeek.amharic; 140 | const monthLabels = monthNames[this.weekdayLang] || monthNames.amharic; 141 | const holidayMap = {}; 142 | holidaysList.forEach(h => { 143 | const key = `${h.ethiopian.year}-${h.ethiopian.month}-${h.ethiopian.day}`; 144 | if (!holidayMap[key]) holidayMap[key] = []; 145 | holidayMap[key].push(h); 146 | }); 147 | 148 | const mapped = rawDays.map(day => { 149 | const eth = day.ethiopian; 150 | const greg = day.gregorian; 151 | const weekday = getWeekday(eth); 152 | const key = `${eth.year}-${eth.month}-${eth.day}`; 153 | let holidays = holidayMap[key] || []; 154 | 155 | if (this.mode === 'christian') { 156 | holidays = holidays.concat(saintsMap[eth.day] || []); 157 | } 158 | 159 | if (this.mode === 'muslim' && weekday === 5) { 160 | const j = holidayInfo.jummah; 161 | holidays.push({ 162 | key: 'jummah', 163 | name: j.name[this.weekdayLang] || j.name.english, 164 | description: j.description[this.weekdayLang] || j.description.english, 165 | tags: [HolidayTags.RELIGIOUS, HolidayTags.MUSLIM] 166 | }); 167 | } 168 | 169 | return { 170 | ethiopian: { 171 | year: this.useGeez ? toGeez(eth.year) : eth.year, 172 | month: this.useGeez ? monthLabels[eth.month - 1] : eth.month, 173 | day: this.useGeez ? toGeez(eth.day) : eth.day 174 | }, 175 | gregorian: greg, 176 | weekday, 177 | weekdayName: labels[weekday], 178 | isToday: eth.year === today.year && eth.month === today.month && eth.day === today.day, 179 | holidays 180 | }; 181 | }); 182 | 183 | const offset = ((mapped.length > 0 ? mapped[0].weekday : (new Date(this.year, this.month - 1, 1).getDay())) - this.weekStart + 7) % 7; 184 | return Array(offset).fill(null).concat(mapped); 185 | } 186 | 187 | _getWeekdayHeaders() { 188 | const labels = daysOfWeek[this.weekdayLang] || daysOfWeek.amharic; 189 | return labels.slice(this.weekStart).concat(labels.slice(0, this.weekStart)); 190 | } 191 | 192 | _getLocalizedMonthName() { 193 | return (monthNames[this.weekdayLang] || monthNames.amharic)[this.month - 1]; 194 | } 195 | 196 | _getLocalizedYear() { 197 | return this.useGeez ? toGeez(this.year) : this.year; 198 | } 199 | 200 | up() { 201 | if (this.month === 13) { 202 | this.month = 1; 203 | this.year++; 204 | } else { 205 | this.month++; 206 | } 207 | return this; 208 | } 209 | 210 | down() { 211 | if (this.month === 1) { 212 | this.month = 13; 213 | this.year--; 214 | } else { 215 | this.month--; 216 | } 217 | return this; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /tests/Time.test.js: -------------------------------------------------------------------------------- 1 | /* /test/Time.test.js */ 2 | 3 | import { Kenat } from '../src/Kenat.js'; 4 | import { Time } from '../src/Time.js'; 5 | import { 6 | InvalidTimeError, 7 | InvalidInputTypeError, 8 | } from '../src/errors/errorHandler.js'; 9 | 10 | describe('Time Class and Related Logic', () => { 11 | //---------------------------------------------------------------- 12 | // 1. Tests for the Time Class Constructor 13 | //---------------------------------------------------------------- 14 | describe('Constructor and Validation', () => { 15 | test('should create a valid Time object', () => { 16 | const time = new Time(3, 30, 'day'); 17 | expect(time.hour).toBe(3); 18 | expect(time.minute).toBe(30); 19 | expect(time.period).toBe('day'); 20 | }); 21 | 22 | test('should default minute and period correctly', () => { 23 | const time = new Time(5); 24 | expect(time.minute).toBe(0); 25 | expect(time.period).toBe('day'); 26 | }); 27 | 28 | test('should throw InvalidTimeError for out-of-range values', () => { 29 | expect(() => new Time(0, 0, 'day')).toThrow(InvalidTimeError); // Hour 0 is invalid 30 | expect(() => new Time(13, 0, 'day')).toThrow(InvalidTimeError); // Hour 13 is invalid 31 | expect(() => new Time(5, -1, 'day')).toThrow(InvalidTimeError); // Negative minute 32 | expect(() => new Time(5, 60, 'day')).toThrow(InvalidTimeError); // Minute >= 60 33 | expect(() => new Time(5, 0, 'morning')).toThrow(InvalidTimeError); // Invalid period 34 | }); 35 | 36 | test('should throw InvalidInputTypeError for non-numeric inputs', () => { 37 | expect(() => new Time('three', 30)).toThrow(InvalidInputTypeError); 38 | expect(() => new Time(3, 'thirty')).toThrow(InvalidInputTypeError); 39 | }); 40 | }); 41 | 42 | //---------------------------------------------------------------- 43 | // 2. Tests for Time.fromString() - Addressing the PR Comment 44 | //---------------------------------------------------------------- 45 | describe('Time.fromString()', () => { 46 | test('should parse valid strings with Arabic numerals', () => { 47 | expect(Time.fromString('10:30 day')).toEqual(new Time(10, 30, 'day')); 48 | expect(Time.fromString('5:00 night')).toEqual(new Time(5, 0, 'night')); 49 | }); 50 | 51 | test('should parse valid strings with Geez numerals', () => { 52 | expect(Time.fromString('፫:፲፭ ማታ')).toEqual(new Time(3, 15, 'night')); 53 | expect(Time.fromString('፲፪:፴ day')).toEqual(new Time(12, 30, 'day')); 54 | }); 55 | 56 | test('should default to "day" period when missing', () => { 57 | expect(Time.fromString('11:45')).toEqual(new Time(11, 45, 'day')); 58 | }); 59 | 60 | test('should handle inconsistent spacing', () => { 61 | expect(Time.fromString(' 4 : 20 night ')).toEqual(new Time(4, 20, 'night')); 62 | }); 63 | 64 | test('should throw InvalidTimeError for malformed strings', () => { 65 | // NOTE: We test for the general InvalidTimeError, as InvalidTimeFormatError has been removed. 66 | expect(() => Time.fromString('10')).toThrow(InvalidTimeError); 67 | expect(() => Time.fromString('10:')).toThrow(InvalidTimeError); 68 | expect(() => Time.fromString(':30')).toThrow(InvalidTimeError); 69 | expect(() => Time.fromString('10 30 day')).toThrow(InvalidTimeError); // No colon 70 | expect(() => Time.fromString('abc:def period')).toThrow(InvalidTimeError); // Throws from constructor 71 | }); 72 | 73 | test('should throw InvalidTimeError for empty or whitespace strings', () => { 74 | // NOTE: We test for the general InvalidTimeError, as InvalidTimeFormatError has been removed. 75 | expect(() => Time.fromString('')).toThrow(InvalidTimeError); 76 | expect(() => Time.fromString(' ')).toThrow(InvalidTimeError); 77 | }); 78 | 79 | test('should throw InvalidTimeError for valid format but out-of-range values', () => { 80 | expect(() => Time.fromString('13:00 day')).toThrow(InvalidTimeError); 81 | expect(() => Time.fromString('5:60 night')).toThrow(InvalidTimeError); 82 | }); 83 | }); 84 | 85 | //---------------------------------------------------------------- 86 | // 3. Tests for Gregorian/Ethiopian Conversions 87 | //---------------------------------------------------------------- 88 | describe('Gregorian-Ethiopian Conversions', () => { 89 | test.each([ 90 | [7, 30, new Time(1, 30, 'day')], 91 | [18, 0, new Time(12, 0, 'night')], 92 | [0, 0, new Time(6, 0, 'night')], 93 | [6, 0, new Time(12, 0, 'day')], 94 | ])('fromGregorian: should convert %i:%i correctly', (gHour, gMinute, expected) => { 95 | expect(Time.fromGregorian(gHour, gMinute)).toEqual(expected); 96 | }); 97 | 98 | test.each([ 99 | [new Time(1, 30, 'day'), { hour: 7, minute: 30 }], 100 | [new Time(12, 0, 'night'), { hour: 18, minute: 0 }], 101 | [new Time(6, 0, 'night'), { hour: 0, minute: 0 }], 102 | [new Time(12, 0, 'day'), { hour: 6, minute: 0 }], 103 | ])('toGregorian: should convert %s correctly', (ethTime, expected) => { 104 | expect(ethTime.toGregorian()).toEqual(expected); 105 | }); 106 | 107 | test('fromGregorian should throw for invalid Gregorian time', () => { 108 | expect(() => Time.fromGregorian(24, 0)).toThrow(InvalidTimeError); 109 | expect(() => Time.fromGregorian(-1, 0)).toThrow(InvalidTimeError); 110 | }); 111 | }); 112 | 113 | //---------------------------------------------------------------- 114 | // 4. Tests for Time Arithmetic 115 | //---------------------------------------------------------------- 116 | describe('Time Arithmetic', () => { 117 | const startTime = new Time(3, 15, 'day'); // 9:15 AM 118 | 119 | test('add: should add hours and minutes correctly within the same period', () => { 120 | const newTime = startTime.add({ hours: 2, minutes: 10 }); 121 | expect(newTime).toEqual(new Time(5, 25, 'day')); // 11:25 AM 122 | }); 123 | 124 | test('add: should handle rolling over to the next period (day to night)', () => { 125 | const newTime = startTime.add({ hours: 9 }); // 9:15 AM + 9 hours = 6:15 PM 126 | expect(newTime).toEqual(new Time(12, 15, 'night')); 127 | }); 128 | 129 | test('subtract: should subtract time correctly', () => { 130 | const newTime = startTime.subtract({ hours: 1, minutes: 15 }); 131 | expect(newTime).toEqual(new Time(2, 0, 'day')); // 8:00 AM 132 | }); 133 | 134 | test('subtract: should handle rolling back to the previous period (day to night)', () => { 135 | const newTime = new Time(1, 0, 'day').subtract({ hours: 2 }); // 7:00 AM - 2 hours = 5:00 AM 136 | expect(newTime).toEqual(new Time(11, 0, 'night')); 137 | }); 138 | 139 | test('diff: should calculate the difference between two times', () => { 140 | const endTime = new Time(5, 45, 'day'); 141 | const difference = startTime.diff(endTime); 142 | expect(difference).toEqual({ hours: 2, minutes: 30 }); 143 | }); 144 | 145 | test('diff: should calculate the shortest difference across the 24h wrap', () => { 146 | const t1 = new Time(2, 0, 'night'); // 8 PM 147 | const t2 = new Time(10, 0, 'night'); // 4 AM 148 | const difference = t1.diff(t2); 149 | expect(difference).toEqual({ hours: 8, minutes: 0 }); // 8 hours difference 150 | }); 151 | 152 | test('add/subtract should throw on invalid duration', () => { 153 | expect(() => startTime.add({ hours: 'two' })).toThrow(InvalidInputTypeError); 154 | expect(() => startTime.subtract('one hour')).toThrow(InvalidTimeError); 155 | }); 156 | }); 157 | 158 | //---------------------------------------------------------------- 159 | // 5. Tests for Formatting 160 | //---------------------------------------------------------------- 161 | describe('Formatting', () => { 162 | test('format: should format with default options (Geez)', () => { 163 | const time = new Time(5, 30, 'day'); 164 | expect(time.format()).toBe('፭:፴ ጠዋት'); 165 | }); 166 | 167 | test('format: should format with Arabic numerals', () => { 168 | const time = new Time(5, 30, 'day'); 169 | expect(time.format({ useGeez: false })).toBe('05:30 day'); 170 | }); 171 | 172 | test('format: should format without period label', () => { 173 | const time = new Time(8, 15, 'night'); 174 | expect(time.format({ useGeez: false, showPeriodLabel: false })).toBe('08:15'); 175 | }); 176 | 177 | test('format: should use a dash for zero minutes', () => { 178 | const time = new Time(12, 0, 'day'); 179 | expect(time.format({ useGeez: false, zeroAsDash: true })).toBe('12:_ day'); 180 | }); 181 | }); 182 | 183 | //---------------------------------------------------------------- 184 | // 6. Tests for Kenat Class Time-Related Methods 185 | //---------------------------------------------------------------- 186 | describe('Kenat Time Methods', () => { 187 | test('getCurrentTime returns a valid Time instance', () => { 188 | const now = new Kenat(); 189 | const ethTime = now.getCurrentTime(); 190 | expect(ethTime).toBeInstanceOf(Time); 191 | expect(ethTime).toHaveProperty('hour'); 192 | expect(ethTime).toHaveProperty('minute'); 193 | expect(ethTime).toHaveProperty('period'); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /tests/dayArtimetic.test.js: -------------------------------------------------------------------------------- 1 | import { Kenat } from '../src/Kenat.js'; 2 | 3 | describe('KenatAddDaysTests', () => { 4 | 5 | test('Add days within same month', () => { 6 | const k = new Kenat('2016/01/10'); 7 | const result = k.addDays(5); 8 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 1, day: 15 }); 9 | }); 10 | 11 | test('Add days crossing month boundary', () => { 12 | const k = new Kenat('2016/01/28'); 13 | const result = k.addDays(5); // Month 1 has 30 days 14 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 2, day: 3 }); 15 | }); 16 | 17 | test('Add days crossing year boundary', () => { 18 | const k = new Kenat('2016/13/4'); // Pagume month with 5 days (non-leap) 19 | const result = k.addDays(3); 20 | expect(result.getEthiopian()).toEqual({ year: 2017, month: 1, day: 2 }); 21 | }); 22 | 23 | test('Add days exactly at month end', () => { 24 | const k = new Kenat('2016/02/25'); 25 | const result = k.addDays(5); 26 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 2, day: 30 }); 27 | }); 28 | 29 | 30 | test('Add zero days returns same date', () => { 31 | const k = new Kenat('2016/05/15'); 32 | const result = k.addDays(0); 33 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 5, day: 15 }); 34 | }); 35 | 36 | test('Add zero days returns same date', () => { 37 | const k = new Kenat('2016/13/1'); 38 | const result = k.addDays(6); 39 | expect(result.getEthiopian()).toEqual({ year: 2017, month: 1, day: 2 }); 40 | }); 41 | 42 | }); 43 | 44 | describe('KenatAddMonthsTests', () => { 45 | test('Add months within same year', () => { 46 | const k = new Kenat('2016/03/10'); 47 | const result = k.addMonths(5); 48 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 8, day: 10 }); 49 | }); 50 | 51 | test('Add months with year rollover', () => { 52 | const k = new Kenat('2016/11/10'); 53 | const result = k.addMonths(3); 54 | expect(result.getEthiopian()).toEqual({ year: 2017, month: 1, day: 10 }); 55 | }); 56 | 57 | test('Add months resulting in Pagume with day clamp', () => { 58 | const k = new Kenat('2015/12/30'); // 2015 is not a leap year (Pagume = 5 days) 59 | const result = k.addMonths(1); // 30th goes to Pagume but 30 > 5, so clamp 60 | expect(result.getEthiopian()).toEqual({ year: 2015, month: 13, day: 6 }); // ✅ real converted result 61 | }); 62 | 63 | test('Add months resulting in Pagume in leap year', () => { 64 | const k = new Kenat('2011/12/30'); // 2011 is a leap year (Pagume = 6 days) 65 | const result = k.addMonths(1); 66 | expect(result.getEthiopian()).toEqual({ year: 2011, month: 13, day: 6 }); 67 | }); 68 | 69 | test('Add zero months returns same date', () => { 70 | const k = new Kenat('2016/06/20'); 71 | const result = k.addMonths(0); 72 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 6, day: 20 }); 73 | }); 74 | 75 | test('Add negative months across year boundary', () => { 76 | const k = new Kenat('2016/03/10'); 77 | const result = k.addMonths(-4); // Goes to 2015/12 (not 11) 78 | expect(result.getEthiopian()).toEqual({ year: 2015, month: 12, day: 10 }); // ✅ fixed 79 | }); 80 | 81 | test('Subtract into Pagume with clamping', () => { 82 | const k = new Kenat('2016/01/06'); // Meskerem 6 83 | const result = k.addMonths(-1); // Should go to Pagume 84 | expect(result.getEthiopian()).toEqual({ year: 2015, month: 13, day: 6 }); // leap year 85 | }); 86 | }); 87 | 88 | describe('KenatAddYearsTests', () => { 89 | test('Add years within leap-safe month', () => { 90 | const k = new Kenat('2010/05/15'); 91 | const result = k.addYears(3); 92 | expect(result.getEthiopian()).toEqual({ year: 2013, month: 5, day: 15 }); 93 | }); 94 | 95 | test('Add years from leap Pagume 6 to non-leap year', () => { 96 | const k = new Kenat('2011/13/6'); // 2011 is leap 97 | const result = k.addYears(1); // 2012 is not leap 98 | expect(result.getEthiopian()).toEqual({ year: 2012, month: 13, day: 5 }); // day clamped 99 | }); 100 | 101 | test('Add years to another leap year, keeping Pagume 6', () => { 102 | const k = new Kenat('2011/13/6'); // 2011 is leap 103 | const result = k.addYears(4); // 2015 is also leap 104 | expect(result.getEthiopian()).toEqual({ year: 2015, month: 13, day: 6 }); 105 | }); 106 | 107 | test('Add zero years returns same date', () => { 108 | const k = new Kenat('2016/03/10'); 109 | const result = k.addYears(0); 110 | expect(result.getEthiopian()).toEqual({ year: 2016, month: 3, day: 10 }); 111 | }); 112 | 113 | test('Subtract years across leap/non-leap transition', () => { 114 | const k = new Kenat('2015/13/6'); // 2015 is leap 115 | const result = k.addYears(-1); // 2014 is not leap 116 | expect(result.getEthiopian()).toEqual({ year: 2014, month: 13, day: 5 }); 117 | }); 118 | }); 119 | 120 | describe('KenatDiffInDaysTests', () => { 121 | test('Same date returns zero', () => { 122 | const a = new Kenat('2016/05/15'); 123 | const b = new Kenat('2016/05/15'); 124 | expect(a.diffInDays(b)).toBe(0); 125 | }); 126 | 127 | test('Later date minus earlier date returns positive', () => { 128 | const a = new Kenat('2016/06/10'); 129 | const b = new Kenat('2016/06/05'); 130 | expect(a.diffInDays(b)).toBe(5); 131 | }); 132 | 133 | test('Earlier date minus later date returns negative', () => { 134 | const a = new Kenat('2016/06/01'); 135 | const b = new Kenat('2016/06/06'); 136 | expect(a.diffInDays(b)).toBe(-5); 137 | }); 138 | 139 | test('Crossing year boundary', () => { 140 | const a = new Kenat('2017/01/03'); 141 | const b = new Kenat('2016/13/04'); 142 | expect(a.diffInDays(b)).toBe(4); // Pagume 5 to Meskerem 3 143 | }); 144 | 145 | test('Crossing multiple years', () => { 146 | const a = new Kenat('2018/01/01'); 147 | const b = new Kenat('2016/01/01'); 148 | expect(a.diffInDays(b)).toBe(730); // 2 Ethiopian years = 365 * 2 149 | }); 150 | }); 151 | 152 | describe('KenatDiffInMonthsTests', () => { 153 | test('Same date returns zero', () => { 154 | const a = new Kenat('2016/05/15'); 155 | const b = new Kenat('2016/05/15'); 156 | expect(a.diffInMonths(b)).toBe(0); 157 | }); 158 | 159 | test('Later date minus earlier date within same year returns positive', () => { 160 | const a = new Kenat('2016/06/10'); 161 | const b = new Kenat('2016/05/05'); 162 | expect(a.diffInMonths(b)).toBe(1); 163 | }); 164 | 165 | test('Earlier date minus later date within same year returns negative', () => { 166 | const a = new Kenat('2016/05/01'); 167 | const b = new Kenat('2016/06/06'); 168 | expect(a.diffInMonths(b)).toBe(-2); 169 | // Explanation: totalMonths difference is -1, but day 1 < 6 subtracts 1 more month = -2 170 | }); 171 | 172 | test('Crossing year boundary', () => { 173 | const a = new Kenat('2017/01/03'); 174 | const b = new Kenat('2016/13/04'); 175 | expect(a.diffInMonths(b)).toBe(0); 176 | }); 177 | 178 | test('Crossing year boundary with day adjustment', () => { 179 | const a = new Kenat('2017/01/05'); // day 5 180 | const b = new Kenat('2016/13/04'); // day 4 181 | expect(a.diffInMonths(b)).toBe(1); 182 | // Because 5 >= 4 no decrement 183 | }); 184 | 185 | test('Crossing multiple years', () => { 186 | const a = new Kenat('2018/01/01'); 187 | const b = new Kenat('2016/01/01'); 188 | expect(a.diffInMonths(b)).toBe(26); // 2 Ethiopian years * 13 months 189 | }); 190 | }); 191 | 192 | describe('KenatDiffInYearsTests', () => { 193 | test('Same date returns zero', () => { 194 | const a = new Kenat('2016/05/15'); 195 | const b = new Kenat('2016/05/15'); 196 | expect(a.diffInYears(b)).toBe(0); 197 | }); 198 | 199 | test('Later date minus earlier date within same year returns zero', () => { 200 | const a = new Kenat('2016/06/10'); 201 | const b = new Kenat('2016/05/05'); 202 | expect(a.diffInYears(b)).toBe(0); // Same year, difference less than full year 203 | }); 204 | 205 | test('Earlier date minus later date within same year returns zero', () => { 206 | const a = new Kenat('2016/05/01'); 207 | const b = new Kenat('2016/06/06'); 208 | expect(a.diffInYears(b)).toBe(0); // Same year, difference less than full year 209 | }); 210 | 211 | test('Later date minus earlier date crossing year boundary returns positive', () => { 212 | const a = new Kenat('2017/01/03'); 213 | const b = new Kenat('2016/13/04'); 214 | expect(a.diffInYears(b)).toBe(0); // Full year difference, day/month adjustment done 215 | }); 216 | 217 | test('Later date minus earlier date crossing year boundary but day adjustment subtracts one', () => { 218 | const a = new Kenat('2017/01/03'); // day 3 < day 4 219 | const b = new Kenat('2016/13/04'); 220 | // Same as above test, but test again to confirm day < day subtracts 1 221 | expect(a.diffInYears(b)).toBe(0); 222 | }); 223 | 224 | test('Crossing multiple years returns correct positive value', () => { 225 | const a = new Kenat('2018/01/01'); 226 | const b = new Kenat('2016/01/01'); 227 | expect(a.diffInYears(b)).toBe(2); 228 | }); 229 | 230 | test('Earlier date minus later date returns negative years', () => { 231 | const a = new Kenat('2016/01/01'); 232 | const b = new Kenat('2018/01/01'); 233 | expect(a.diffInYears(b)).toBe(-2); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /examples/vanilla/month/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |