| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>Ethiopian Time Clock</title> | ||
| <style> | ||
| body { | ||
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||
| text-align: center; | ||
| padding: 2rem; | ||
| background: #f9f9f9; | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>Ethiopian Time Clock</title> | ||
| <style> | ||
| body { | ||
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||
| text-align: center; | ||
| padding: 2rem; | ||
| background: #f9f9f9; | ||
| } | ||
| #digitalClock { | ||
| font-size: 2.5rem; | ||
| margin-bottom: 1rem; | ||
| } | ||
| #geezToggle { | ||
| margin-bottom: 2rem; | ||
| padding: 0.5rem 1rem; | ||
| font-size: 1rem; | ||
| cursor: pointer; | ||
| } | ||
| #analogClock { | ||
| margin: 0 auto; | ||
| background: #fff; | ||
| border: 5px solid #333; | ||
| border-radius: 50%; | ||
| width: 250px; | ||
| height: 250px; | ||
| position: relative; | ||
| } | ||
| #analogClock canvas { | ||
| display: block; | ||
| margin: 0 auto; | ||
| background: transparent; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <h1>Ethiopian Time Clock</h1> | ||
| <button id="geezToggle">Use Geez Numerals: ON</button> | ||
| <div id="digitalClock">--:-- --</div> | ||
| <div id="analogClock"> | ||
| <canvas id="clockCanvas" width="250" height="250"></canvas> | ||
| </div> | ||
| <script type="module"> | ||
| // --- Start of Embedded Library Code --- | ||
| // Note: GeezConverterError is simplified for this standalone file. | ||
| class GeezConverterError extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = 'GeezConverterError'; | ||
| } | ||
| } | ||
| #digitalClock { | ||
| font-size: 2.5rem; | ||
| margin-bottom: 1rem; | ||
| const geezSymbols = { | ||
| ones: ['', '፩', '፪', '፫', '፬', '፭', '፮', '፯', '፰', '፱'], | ||
| tens: ['', '፲', '፳', '፴', '፵', '፶', '፷', '፸', '፹', '፺'], | ||
| hundred: '፻', | ||
| tenThousand: '፼' | ||
| }; | ||
| /** | ||
| * Converts a natural number to Ethiopic numeral string. | ||
| */ | ||
| function toGeez(input) { | ||
| if (typeof input !== 'number' && typeof input !== 'string') { | ||
| throw new GeezConverterError("Input must be a number or a string."); | ||
| } | ||
| const num = Number(input); | ||
| if (isNaN(num) || !Number.isInteger(num) || num < 0) { | ||
| throw new GeezConverterError("Input must be a non-negative integer."); | ||
| } | ||
| if (num === 0) return '0'; | ||
| function convertBelow100(n) { | ||
| if (n <= 0) return ''; | ||
| const tensDigit = Math.floor(n / 10); | ||
| const onesDigit = n % 10; | ||
| return geezSymbols.tens[tensDigit] + geezSymbols.ones[onesDigit]; | ||
| } | ||
| if (num < 100) { | ||
| return convertBelow100(num); | ||
| } | ||
| if (num === 100) return geezSymbols.hundred; | ||
| if (num < 10000) { | ||
| const hundreds = Math.floor(num / 100); | ||
| const remainder = num % 100; | ||
| const hundredPart = (hundreds > 1 ? convertBelow100(hundreds) : '') + geezSymbols.hundred; | ||
| return hundredPart + convertBelow100(remainder); | ||
| } | ||
| const tenThousandPart = Math.floor(num / 10000); | ||
| const remainder = num % 10000; | ||
| const tenThousandGeez = (tenThousandPart > 1 ? toGeez(tenThousandPart) : '') + geezSymbols.tenThousand; | ||
| return tenThousandGeez + (remainder > 0 ? toGeez(remainder) : ''); | ||
| } | ||
| #geezToggle { | ||
| margin-bottom: 2rem; | ||
| padding: 0.5rem 1rem; | ||
| font-size: 1rem; | ||
| // Note: InvalidTimeError is simplified for this standalone file. | ||
| class InvalidTimeError extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = 'InvalidTimeError'; | ||
| } | ||
| } | ||
| #analogClock { | ||
| margin: 0 auto; | ||
| background: #fff; | ||
| border: 5px solid #333; | ||
| border-radius: 50%; | ||
| width: 250px; | ||
| height: 250px; | ||
| position: relative; | ||
| class Time { | ||
| constructor(hour, minute = 0, period = 'day') { | ||
| if (hour < 1 || hour > 12) { | ||
| throw new InvalidTimeError(`Invalid Ethiopian hour: ${hour}. Must be between 1 and 12.`); | ||
| } | ||
| if (minute < 0 || minute > 59) { | ||
| throw new InvalidTimeError(`Invalid minute: ${minute}. Must be between 0 and 59.`); | ||
| } | ||
| if (period !== 'day' && period !== 'night') { | ||
| throw new InvalidTimeError(`Invalid period: "${period}". Must be 'day' or 'night'.`); | ||
| } | ||
| this.hour = hour; | ||
| this.minute = minute; | ||
| this.period = period; | ||
| } | ||
| static fromGregorian(hour, minute = 0) { | ||
| if (hour < 0 || hour > 23) { | ||
| throw new InvalidTimeError(`Invalid Gregorian hour: ${hour}. Must be between 0 and 23.`); | ||
| } | ||
| if (minute < 0 || minute > 59) { | ||
| throw new InvalidTimeError(`Invalid minute: ${minute}. Must be between 0 and 59.`); | ||
| } | ||
| let tempHour = hour - 6; | ||
| if (tempHour < 0) { | ||
| tempHour += 24; | ||
| } | ||
| const period = (tempHour < 12) ? 'day' : 'night'; | ||
| let ethHour = tempHour % 12; | ||
| ethHour = (ethHour === 0) ? 12 : ethHour; | ||
| return new Time(ethHour, minute, period); | ||
| } | ||
| format(options = {}) { | ||
| const defaultLang = options.useGeez === false ? 'english' : 'amharic'; | ||
| const { lang = defaultLang, useGeez = true, showPeriodLabel = true, zeroAsDash = true } = options; | ||
| const formatNum = (num) => { | ||
| if (useGeez) return toGeez(num); | ||
| return num.toString().padStart(2, '0'); | ||
| }; | ||
| const hourStr = formatNum(this.hour); | ||
| let minuteStr; | ||
| if (zeroAsDash && this.minute === 0) { | ||
| minuteStr = '_'; | ||
| } else { | ||
| minuteStr = useGeez ? toGeez(this.minute) : this.minute.toString().padStart(2, '0'); | ||
| } | ||
| let periodLabel = ''; | ||
| if (showPeriodLabel) { | ||
| if (lang === 'english') { | ||
| periodLabel = this.period; | ||
| } else { | ||
| const amharicLabels = { day: 'ጠዋት', night: 'ማታ' }; | ||
| periodLabel = amharicLabels[this.period]; | ||
| } | ||
| } | ||
| const label = periodLabel ? ` ${periodLabel}` : ''; | ||
| return `${hourStr}:${minuteStr}${label}`; | ||
| } | ||
| } | ||
| #analogClock canvas { | ||
| display: block; | ||
| margin: 0 auto; | ||
| background: transparent; | ||
| // --- End of Embedded Library Code --- | ||
| // --- Start of Clock Application Code --- | ||
| const digitalClock = document.getElementById('digitalClock'); | ||
| const geezToggleBtn = document.getElementById('geezToggle'); | ||
| const canvas = document.getElementById('clockCanvas'); | ||
| const ctx = canvas.getContext('2d'); | ||
| const radius = canvas.height / 2; | ||
| ctx.translate(radius, radius); | ||
| let useGeez = true; | ||
| geezToggleBtn.addEventListener('click', () => { | ||
| useGeez = !useGeez; | ||
| geezToggleBtn.textContent = `Use Geez Numerals: ${useGeez ? 'ON' : 'OFF'}`; | ||
| // No need to call drawClock immediately, the update loop will handle it. | ||
| }); | ||
| function drawClock() { | ||
| drawFace(ctx, radius); | ||
| drawNumbers(ctx, radius); | ||
| drawTime(ctx, radius); | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <h1>Ethiopian Time Clock</h1> | ||
| <button id="geezToggle">Use Geez Numerals: ON</button> | ||
| <div id="digitalClock">--:-- --</div> | ||
| <div id="analogClock"> | ||
| <canvas id="clockCanvas" width="250" height="250"></canvas> | ||
| </div> | ||
| <script type="module" src="ethiopian-clock.js"></script> | ||
| function drawFace(ctx, radius) { | ||
| ctx.beginPath(); | ||
| ctx.arc(0, 0, radius, 0, 2 * Math.PI); | ||
| ctx.fillStyle = 'white'; | ||
| ctx.fill(); | ||
| ctx.strokeStyle = '#333'; | ||
| ctx.lineWidth = radius * 0.05; | ||
| ctx.stroke(); | ||
| ctx.beginPath(); | ||
| ctx.arc(0, 0, radius * 0.05, 0, 2 * Math.PI); | ||
| ctx.fillStyle = '#333'; | ||
| ctx.fill(); | ||
| } | ||
| function drawNumbers(ctx, radius) { | ||
| const angIncrement = (2 * Math.PI) / 12; | ||
| ctx.font = `${radius * 0.15}px Arial`; | ||
| ctx.textBaseline = 'middle'; | ||
| ctx.textAlign = 'center'; | ||
| for (let num = 1; num <= 12; num++) { | ||
| let numeral = useGeez ? toGeez(num) : num.toString(); | ||
| // Position numbers on the clock face | ||
| let ang = num * angIncrement - (Math.PI / 2); // Adjust to start 1 at the top-right | ||
| let x = radius * 0.85 * Math.cos(ang); | ||
| let y = radius * 0.85 * Math.sin(ang); | ||
| ctx.fillStyle = '#000'; | ||
| ctx.fillText(numeral, x, y); | ||
| } | ||
| } | ||
| function drawTime(ctx, radius) { | ||
| const now = new Date(); | ||
| const time = Time.fromGregorian(now.getHours(), now.getMinutes()); | ||
| const hour = time.hour; | ||
| const minute = time.minute; | ||
| const second = now.getSeconds(); | ||
| // Hour hand angle calculation | ||
| const hourForAngle = hour % 12 + minute / 60; // Get a fractional hour (e.g., 1.5 for 1:30) | ||
| const hourAngle = (hourForAngle * Math.PI) / 6 - Math.PI / 2; | ||
| drawHand(ctx, hourAngle, radius * 0.5, radius * 0.07); | ||
| // Minute hand angle | ||
| const minuteAngle = (minute * Math.PI) / 30 - Math.PI / 2; | ||
| drawHand(ctx, minuteAngle, radius * 0.75, radius * 0.05); | ||
| // Second hand angle | ||
| const secondAngle = (second * Math.PI) / 30 - Math.PI / 2; | ||
| drawHand(ctx, secondAngle, radius * 0.85, radius * 0.02, 'red'); | ||
| digitalClock.textContent = time.format({ useGeez, showPeriodLabel: true, zeroAsDash: false }); | ||
| } | ||
| function drawHand(ctx, pos, length, width, color = '#333') { | ||
| ctx.beginPath(); | ||
| ctx.lineWidth = width; | ||
| ctx.lineCap = 'round'; | ||
| ctx.strokeStyle = color; | ||
| ctx.moveTo(0, 0); | ||
| ctx.lineTo(length * Math.cos(pos), length * Math.sin(pos)); | ||
| ctx.stroke(); | ||
| } | ||
| function update() { | ||
| ctx.clearRect(-radius, -radius, canvas.width, canvas.height); | ||
| drawClock(); | ||
| requestAnimationFrame(update); | ||
| } | ||
| // Start the clock | ||
| update(); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| </html> |
+1
-1
| { | ||
| "name": "kenat", | ||
| "version": "2.0.2", | ||
| "version": "2.0.3", | ||
| "description": "A JavaScript library for the Ethiopian calendar with date and time support.", | ||
@@ -5,0 +5,0 @@ "main": "src/index.js", |
@@ -167,2 +167,2 @@ /* /src/errors/index.js */ | ||
| } | ||
| } | ||
| } |
+10
-7
@@ -34,3 +34,3 @@ import { toGeez } from './geezConverter.js'; | ||
| * @param {{year: number, month: number, day: number}} etDate - Ethiopian date | ||
| * @param {{hour: number, minute: number, period: 'day'|'night'}} time - Ethiopian time | ||
| * @param {import('../Time.js').Time} time - An instance of the Time class | ||
| * @param {'amharic'|'english'} [lang='amharic'] - Language for suffix | ||
@@ -41,8 +41,11 @@ * @returns {string} Example: "መስከረም 10 2016 08:30 ጠዋት" | ||
| const base = formatStandard(etDate, lang); | ||
| const hour = time?.hour?.toString().padStart(2, '0') ?? '??'; | ||
| const minute = time?.minute?.toString().padStart(2, '0') ?? '??'; | ||
| const suffix = lang === 'amharic' | ||
| ? (time?.period === 'day' ? 'ጠዋት' : 'ማታ') | ||
| : (time?.period === 'day' ? 'day' : 'night'); | ||
| return `${base} ${hour}:${minute} ${suffix}`; | ||
| // THIS IS THE FIX: Ensure zeroAsDash is false for this specific format. | ||
| const timeString = time.format({ | ||
| lang, | ||
| useGeez: false, | ||
| zeroAsDash: false | ||
| }); | ||
| return `${base} ${timeString}`; | ||
| } | ||
@@ -49,0 +52,0 @@ |
+18
-28
| import { toGC, toEC } from './conversions.js'; | ||
| import { printMonthCalendarGrid } from './render/printMonthCalendarGrid.js'; | ||
| import { monthNames, daysOfWeek } from './constants.js'; | ||
| import { toEthiopianTime, toGregorianTime } from './ethiopianTime.js'; | ||
| import { Time } from './Time.js'; | ||
| import { toGeez } from './geezConverter.js'; | ||
@@ -69,3 +69,5 @@ import { getBahireHasab } from './bahireHasab.js'; | ||
| day = ethiopianToday.day; | ||
| this.time = toEthiopianTime(today.getHours(), today.getMinutes()); | ||
| // MODIFICATION: Use the Time class | ||
| this.time = Time.fromGregorian(today.getHours(), today.getMinutes()); | ||
| } else if (input instanceof Date) { | ||
@@ -81,3 +83,5 @@ // Input is a JS Date object | ||
| day = ethiopianDate.day; | ||
| this.time = toEthiopianTime(input.getHours(), input.getMinutes()); | ||
| // MODIFICATION: Use the Time class | ||
| this.time = Time.fromGregorian(input.getHours(), input.getMinutes()); | ||
| } else if (typeof input === 'object' && input !== null && 'year' in input && 'month' in input && 'day' in input) { | ||
@@ -88,5 +92,6 @@ // Input is an object { year, month, day } | ||
| day = input.day; | ||
| this.time = timeObj || { hour: 12, minute: 0, period: 'day' }; | ||
| // MODIFICATION: Create a Time instance | ||
| const t = timeObj || { hour: 12, minute: 0, period: 'day' }; | ||
| this.time = new Time(t.hour, t.minute, t.period); | ||
| } else if (typeof input === 'string') { | ||
| // Input is a string, try to parse it | ||
| const parts = input.split(/[-/]/).map(Number); | ||
@@ -96,12 +101,11 @@ if (parts.length === 3 && !parts.some(isNaN)) { | ||
| } else { | ||
| // Throw the new custom error for bad string formats | ||
| throw new InvalidDateFormatError(input); | ||
| } | ||
| this.time = timeObj || { hour: 12, minute: 0, period: 'day' }; | ||
| // MODIFICATION: Create a Time instance | ||
| const t = timeObj || { hour: 12, minute: 0, period: 'day' }; | ||
| this.time = new Time(t.hour, t.minute, t.period); | ||
| } else { | ||
| // Throw the new custom error for any other unrecognized type | ||
| throw new UnrecognizedInputError(input); | ||
| } | ||
| // Centralized validation | ||
| if (!isValidEthiopianDate(year, month, day)) { | ||
@@ -150,3 +154,3 @@ throw new InvalidEthiopianDateError(year, month, day); | ||
| setTime(hour, minute, period) { | ||
| this.time = { hour, minute, period }; | ||
| this.time = new Time(hour, minute, period); | ||
| } | ||
@@ -198,3 +202,3 @@ | ||
| if (showWeekday && includeTime) { | ||
| return `${formatWithWeekday(this.ethiopian, lang, useGeez)} ${Kenat.formatEthiopianTime(this.time, lang)}`; | ||
| return `${formatWithWeekday(this.ethiopian, lang, useGeez)} ${this.time.format({ lang, useGeez })}`; | ||
| } | ||
@@ -321,3 +325,3 @@ | ||
| static getMonthCalendar(year, month, options = {}) { | ||
@@ -344,3 +348,3 @@ const { useGeez = false, weekdayLang = 'amharic', weekStart = 0, holidayFilter = null } = options; | ||
| /** | ||
@@ -461,19 +465,5 @@ * Generates a full-year calendar as an array of month objects for the specified year. | ||
| const minute = now.getMinutes(); | ||
| return toEthiopianTime(hour, minute); | ||
| return Time.fromGregorian(hour, minute); | ||
| } | ||
| /** | ||
| * Formats an Ethiopian time object. | ||
| * | ||
| * @param {{ hour: number, minute: number, period: 'day' | 'night' }} timeObj - Ethiopian time. | ||
| * @param {'amharic' | 'english'} [lang='amharic'] - Output language. | ||
| * @returns {string} Formatted Ethiopian time. | ||
| */ | ||
| static formatEthiopianTime(timeObj, lang = 'amharic') { | ||
| const { hour, minute, period } = timeObj; | ||
| const suffix = lang === 'amharic' | ||
| ? (period === 'day' ? 'ጠዋት' : 'ማታ') | ||
| : (period === 'day' ? 'day' : 'night'); | ||
| return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')} ${suffix}`; | ||
| } | ||
@@ -480,0 +470,0 @@ /** |
+89
-12
@@ -1,2 +0,2 @@ | ||
| import { toGeez } from './geezConverter.js'; | ||
| import { toGeez, toArabic } from './geezConverter.js'; | ||
| import { PERIOD_LABELS } from './constants.js'; | ||
@@ -82,3 +82,80 @@ import { InvalidTimeError } from './errors/errorHandler.js'; | ||
| /** | ||
| * Creates a `Time` object from a string representation. | ||
| * | ||
| * This static method parses a time string, which can include hours, minutes, and an optional period (day/night). | ||
| * It supports both Arabic numerals (e.g., "1", "30") and Ethiopic numerals (e.g., "፩", "፴") for hours and minutes, | ||
| * assuming a `toArabic` utility function is available to convert Ethiopic numerals to Arabic numbers. | ||
| * | ||
| * The time string must contain a colon (`:`) separating the hour and minute. | ||
| * | ||
| * @static | ||
| * @param {string} timeString - The string representation of the time. | ||
| * Expected formats: | ||
| * - "HH:MM" (e.g., "6:30", "፮:፴") | ||
| * - "HH:MM period" (e.g., "6:30 night", "፮:፴ ማታ") | ||
| * Where: | ||
| * - HH: Hour (Arabic or Ethiopic numeral). | ||
| * - MM: Minute (Arabic or Ethiopic numeral). | ||
| * - period: Optional. Case-insensitive. Recognized values are "night" or "ማታ". | ||
| * If the period is omitted, or if a third part is present but not recognized as "night" or "ማታ", | ||
| * the time is assumed to be in the 'day' period. | ||
| * | ||
| * @returns {Time} A new `Time` object representing the parsed time. | ||
| * | ||
| * @throws {InvalidTimeError} If the `timeString` is: | ||
| * - Not a string or an empty string. | ||
| * - Missing the colon (`:`) separator. | ||
| * - Formatted incorrectly (e.g., not enough parts after splitting). | ||
| * - Contains non-numeric values for hour or minute that cannot be parsed into numbers | ||
| * (neither as Arabic nor as Ethiopic numerals via `toArabic`). | ||
| * | ||
| */ | ||
| static fromString(timeString) { | ||
| if (typeof timeString !== 'string' || timeString.trim() === '') { | ||
| throw new InvalidTimeError(`Input must be a non-empty string, but received "${timeString}".`); | ||
| } | ||
| if (!timeString.includes(':')) { | ||
| throw new InvalidTimeError(`Invalid time string format: "${timeString}". Time must include a colon ':' separator.`); | ||
| } | ||
| const parseNumber = (str) => { | ||
| const arabicNum = parseInt(str, 10); | ||
| if (!isNaN(arabicNum)) { | ||
| return arabicNum; | ||
| } | ||
| try { | ||
| return toArabic(str); | ||
| } catch (e) { | ||
| return NaN; | ||
| } | ||
| }; | ||
| const parts = timeString.split(/[:\s]+/).filter(p => p); | ||
| if (parts.length < 2) { | ||
| throw new InvalidTimeError(`Invalid time string format: "${timeString}".`); | ||
| } | ||
| const hour = parseNumber(parts[0]); | ||
| const minute = parseNumber(parts[1]); | ||
| if (isNaN(hour) || isNaN(minute)) { | ||
| throw new InvalidTimeError(`Invalid number in time string: "${timeString}"`); | ||
| } | ||
| let period = 'day'; | ||
| if (parts.length > 2) { | ||
| const periodStr = parts[2].toLowerCase(); | ||
| if (periodStr === 'night' || periodStr === 'ማታ') { | ||
| period = 'night'; | ||
| } | ||
| } | ||
| return new Time(hour, minute, period); | ||
| } | ||
| // Time Artimatic | ||
| /** | ||
| * Adds a duration to the current time. | ||
@@ -98,9 +175,9 @@ * @param {{hours?: number, minutes?: number}} duration - Object with hours and/or minutes to add. | ||
| totalMinutes = ((totalMinutes % 1440) + 1440) % 1440; // Normalize to a 24-hour cycle | ||
| const newHour = Math.floor(totalMinutes / 60); | ||
| const newMinute = totalMinutes % 60; | ||
| return Time.fromGregorian(newHour, newMinute); | ||
| } | ||
| /** | ||
@@ -138,3 +215,3 @@ * Subtracts a duration from the current time. | ||
| if (diff > 720) diff = 1440 - diff; | ||
| return { | ||
@@ -173,15 +250,15 @@ hours: Math.floor(diff / 60), | ||
| } | ||
| let periodLabel = ''; | ||
| if (showPeriodLabel) { | ||
| if (lang === 'english') { | ||
| // Use English labels for the period | ||
| periodLabel = this.period; // 'day' or 'night' | ||
| // Use English labels for the period | ||
| periodLabel = this.period; // 'day' or 'night' | ||
| } else { | ||
| // Default to Amharic labels from constants | ||
| const amharicLabels = { day: 'ጠዋት', night: 'ማታ' }; | ||
| periodLabel = amharicLabels[this.period]; | ||
| // Default to Amharic labels from constants | ||
| const amharicLabels = { day: 'ጠዋት', night: 'ማታ' }; | ||
| periodLabel = amharicLabels[this.period]; | ||
| } | ||
| } | ||
| const label = periodLabel ? ` ${periodLabel}` : ''; | ||
@@ -188,0 +265,0 @@ return `${hourStr}:${minuteStr}${label}`; |
+161
-116
| /* /test/Time.test.js */ | ||
| import { Kenat } from '../src/Kenat.js'; | ||
| import { Time } from '../src/Time.js'; | ||
| import { InvalidTimeError, InvalidInputTypeError } from '../src/errors/errorHandler.js'; | ||
| import { | ||
| InvalidTimeError, | ||
| InvalidInputTypeError, | ||
| } from '../src/errors/errorHandler.js'; | ||
| describe('Time Class', () => { | ||
| describe('Time Class and Related Logic', () => { | ||
| //---------------------------------------------------------------- | ||
| // 1. Tests for the Time Class Constructor | ||
| //---------------------------------------------------------------- | ||
| describe('Constructor and Validation', () => { | ||
| test('should create a valid Time object', () => { | ||
| const time = new Time(3, 30, 'day'); | ||
| expect(time.hour).toBe(3); | ||
| expect(time.minute).toBe(30); | ||
| expect(time.period).toBe('day'); | ||
| }); | ||
| describe('Constructor and Validation', () => { | ||
| test('should create a valid Time object', () => { | ||
| const time = new Time(3, 30, 'day'); | ||
| expect(time.hour).toBe(3); | ||
| expect(time.minute).toBe(30); | ||
| expect(time.period).toBe('day'); | ||
| }); | ||
| test('should default minute and period correctly', () => { | ||
| const time = new Time(5); | ||
| expect(time.minute).toBe(0); | ||
| expect(time.period).toBe('day'); | ||
| }); | ||
| test('should default minute and period correctly', () => { | ||
| const time = new Time(5); | ||
| expect(time.minute).toBe(0); | ||
| expect(time.period).toBe('day'); | ||
| }); | ||
| test('should throw InvalidTimeError for out-of-range values', () => { | ||
| expect(() => new Time(0, 0, 'day')).toThrow(InvalidTimeError); // Hour 0 is invalid | ||
| expect(() => new Time(13, 0, 'day')).toThrow(InvalidTimeError); // Hour 13 is invalid | ||
| expect(() => new Time(5, -1, 'day')).toThrow(InvalidTimeError); // Negative minute | ||
| expect(() => new Time(5, 60, 'day')).toThrow(InvalidTimeError); // Minute >= 60 | ||
| expect(() => new Time(5, 0, 'morning')).toThrow(InvalidTimeError); // Invalid period | ||
| }); | ||
| test('should throw InvalidTimeError for out-of-range values', () => { | ||
| expect(() => new Time(0, 0, 'day')).toThrow(InvalidTimeError); // Hour 0 is invalid | ||
| expect(() => new Time(13, 0, 'day')).toThrow(InvalidTimeError); // Hour 13 is invalid | ||
| expect(() => new Time(5, -1, 'day')).toThrow(InvalidTimeError); // Negative minute | ||
| expect(() => new Time(5, 60, 'day')).toThrow(InvalidTimeError); // Minute >= 60 | ||
| expect(() => new Time(5, 0, 'morning')).toThrow(InvalidTimeError); // Invalid period | ||
| }); | ||
| test('should throw InvalidInputTypeError for non-numeric inputs', () => { | ||
| expect(() => new Time('three', 30)).toThrow(InvalidInputTypeError); | ||
| expect(() => new Time(3, 'thirty')).toThrow(InvalidInputTypeError); | ||
| }); | ||
| }); | ||
| test('should throw InvalidInputTypeError for non-numeric inputs', () => { | ||
| expect(() => new Time('three', 30)).toThrow(InvalidInputTypeError); | ||
| expect(() => new Time(3, 'thirty')).toThrow(InvalidInputTypeError); | ||
| }); | ||
| //---------------------------------------------------------------- | ||
| // 2. Tests for Time.fromString() - Addressing the PR Comment | ||
| //---------------------------------------------------------------- | ||
| describe('Time.fromString()', () => { | ||
| test('should parse valid strings with Arabic numerals', () => { | ||
| expect(Time.fromString('10:30 day')).toEqual(new Time(10, 30, 'day')); | ||
| expect(Time.fromString('5:00 night')).toEqual(new Time(5, 0, 'night')); | ||
| }); | ||
| describe('Gregorian-Ethiopian Conversions', () => { | ||
| test('fromGregorian: 7:30 (Morning) -> 1:30 Day', () => { | ||
| const ethTime = Time.fromGregorian(7, 30); | ||
| expect(ethTime).toEqual(new Time(1, 30, 'day')); | ||
| }); | ||
| test('should parse valid strings with Geez numerals', () => { | ||
| expect(Time.fromString('፫:፲፭ ማታ')).toEqual(new Time(3, 15, 'night')); | ||
| expect(Time.fromString('፲፪:፴ day')).toEqual(new Time(12, 30, 'day')); | ||
| }); | ||
| test('toGregorian: 1:30 Day -> 7:30', () => { | ||
| const gregTime = new Time(1, 30, 'day').toGregorian(); | ||
| expect(gregTime).toEqual({ hour: 7, minute: 30 }); | ||
| }); | ||
| test('should default to "day" period when missing', () => { | ||
| expect(Time.fromString('11:45')).toEqual(new Time(11, 45, 'day')); | ||
| }); | ||
| test('fromGregorian: 18:00 (Evening) -> 12:00 Night', () => { | ||
| const ethTime = Time.fromGregorian(18, 0); | ||
| expect(ethTime).toEqual(new Time(12, 0, 'night')); | ||
| }); | ||
| test('should handle inconsistent spacing', () => { | ||
| expect(Time.fromString(' 4 : 20 night ')).toEqual(new Time(4, 20, 'night')); | ||
| }); | ||
| test('toGregorian: 12:00 Night -> 18:00', () => { | ||
| const gregTime = new Time(12, 0, 'night').toGregorian(); | ||
| expect(gregTime).toEqual({ hour: 18, minute: 0 }); | ||
| }); | ||
| test('should throw InvalidTimeError for malformed strings', () => { | ||
| // NOTE: We test for the general InvalidTimeError, as InvalidTimeFormatError has been removed. | ||
| expect(() => Time.fromString('10')).toThrow(InvalidTimeError); | ||
| expect(() => Time.fromString('10:')).toThrow(InvalidTimeError); | ||
| expect(() => Time.fromString(':30')).toThrow(InvalidTimeError); | ||
| expect(() => Time.fromString('10 30 day')).toThrow(InvalidTimeError); // No colon | ||
| expect(() => Time.fromString('abc:def period')).toThrow(InvalidTimeError); // Throws from constructor | ||
| }); | ||
| test('fromGregorian: 0:00 (Midnight) -> 6:00 Night', () => { | ||
| const ethTime = Time.fromGregorian(0, 0); | ||
| expect(ethTime).toEqual(new Time(6, 0, 'night')); | ||
| }); | ||
| test('should throw InvalidTimeError for empty or whitespace strings', () => { | ||
| // NOTE: We test for the general InvalidTimeError, as InvalidTimeFormatError has been removed. | ||
| expect(() => Time.fromString('')).toThrow(InvalidTimeError); | ||
| expect(() => Time.fromString(' ')).toThrow(InvalidTimeError); | ||
| }); | ||
| test('toGregorian: 6:00 Night -> 0:00', () => { | ||
| const gregTime = new Time(6, 0, 'night').toGregorian(); | ||
| expect(gregTime).toEqual({ hour: 0, minute: 0 }); | ||
| }); | ||
| test('should throw InvalidTimeError for valid format but out-of-range values', () => { | ||
| expect(() => Time.fromString('13:00 day')).toThrow(InvalidTimeError); | ||
| expect(() => Time.fromString('5:60 night')).toThrow(InvalidTimeError); | ||
| }); | ||
| }); | ||
| test('fromGregorian: 6:00 (Morning) -> 12:00 Day', () => { | ||
| const ethTime = Time.fromGregorian(6, 0); | ||
| expect(ethTime).toEqual(new Time(12, 0, 'day')); | ||
| }); | ||
| //---------------------------------------------------------------- | ||
| // 3. Tests for Gregorian/Ethiopian Conversions | ||
| //---------------------------------------------------------------- | ||
| describe('Gregorian-Ethiopian Conversions', () => { | ||
| test.each([ | ||
| [7, 30, new Time(1, 30, 'day')], | ||
| [18, 0, new Time(12, 0, 'night')], | ||
| [0, 0, new Time(6, 0, 'night')], | ||
| [6, 0, new Time(12, 0, 'day')], | ||
| ])('fromGregorian: should convert %i:%i correctly', (gHour, gMinute, expected) => { | ||
| expect(Time.fromGregorian(gHour, gMinute)).toEqual(expected); | ||
| }); | ||
| test('toGregorian: 12:00 Day -> 6:00', () => { | ||
| const gregTime = new Time(12, 0, 'day').toGregorian(); | ||
| expect(gregTime).toEqual({ hour: 6, minute: 0 }); | ||
| }); | ||
| test.each([ | ||
| [new Time(1, 30, 'day'), { hour: 7, minute: 30 }], | ||
| [new Time(12, 0, 'night'), { hour: 18, minute: 0 }], | ||
| [new Time(6, 0, 'night'), { hour: 0, minute: 0 }], | ||
| [new Time(12, 0, 'day'), { hour: 6, minute: 0 }], | ||
| ])('toGregorian: should convert %s correctly', (ethTime, expected) => { | ||
| expect(ethTime.toGregorian()).toEqual(expected); | ||
| }); | ||
| test('fromGregorian should throw for invalid Gregorian time', () => { | ||
| expect(() => Time.fromGregorian(24, 0)).toThrow(InvalidTimeError); | ||
| expect(() => Time.fromGregorian(-1, 0)).toThrow(InvalidTimeError); | ||
| }); | ||
| test('fromGregorian should throw for invalid Gregorian time', () => { | ||
| expect(() => Time.fromGregorian(24, 0)).toThrow(InvalidTimeError); | ||
| expect(() => Time.fromGregorian(-1, 0)).toThrow(InvalidTimeError); | ||
| }); | ||
| }); | ||
| describe('Time Arithmetic', () => { | ||
| const startTime = new Time(3, 15, 'day'); // 9:15 AM | ||
| //---------------------------------------------------------------- | ||
| // 4. Tests for Time Arithmetic | ||
| //---------------------------------------------------------------- | ||
| describe('Time Arithmetic', () => { | ||
| const startTime = new Time(3, 15, 'day'); // 9:15 AM | ||
| test('add: should add hours and minutes correctly within the same period', () => { | ||
| const newTime = startTime.add({ hours: 2, minutes: 10 }); | ||
| expect(newTime).toEqual(new Time(5, 25, 'day')); // 11:25 AM | ||
| }); | ||
| test('add: should add hours and minutes correctly within the same period', () => { | ||
| const newTime = startTime.add({ hours: 2, minutes: 10 }); | ||
| expect(newTime).toEqual(new Time(5, 25, 'day')); // 11:25 AM | ||
| }); | ||
| test('add: should handle rolling over to the next period (day to night)', () => { | ||
| const newTime = startTime.add({ hours: 9 }); // 9:15 AM + 9 hours = 6:15 PM | ||
| expect(newTime).toEqual(new Time(12, 15, 'night')); | ||
| }); | ||
| test('add: should handle rolling over to the next period (day to night)', () => { | ||
| const newTime = startTime.add({ hours: 9 }); // 9:15 AM + 9 hours = 6:15 PM | ||
| expect(newTime).toEqual(new Time(12, 15, 'night')); | ||
| }); | ||
| test('subtract: should subtract time correctly', () => { | ||
| const newTime = startTime.subtract({ hours: 1, minutes: 15 }); | ||
| expect(newTime).toEqual(new Time(2, 0, 'day')); // 8:00 AM | ||
| }); | ||
| test('subtract: should subtract time correctly', () => { | ||
| const newTime = startTime.subtract({ hours: 1, minutes: 15 }); | ||
| expect(newTime).toEqual(new Time(2, 0, 'day')); // 8:00 AM | ||
| }); | ||
| test('subtract: should handle rolling back to the previous period (day to night)', () => { | ||
| const newTime = new Time(1, 0, 'day').subtract({ hours: 2 }); // 7:00 AM - 2 hours = 5:00 AM | ||
| expect(newTime).toEqual(new Time(11, 0, 'night')); | ||
| }); | ||
| test('subtract: should handle rolling back to the previous period (day to night)', () => { | ||
| const newTime = new Time(1, 0, 'day').subtract({ hours: 2 }); // 7:00 AM - 2 hours = 5:00 AM | ||
| expect(newTime).toEqual(new Time(11, 0, 'night')); | ||
| }); | ||
| test('diff: should calculate the difference between two times', () => { | ||
| const endTime = new Time(5, 45, 'day'); | ||
| const difference = startTime.diff(endTime); | ||
| expect(difference).toEqual({ hours: 2, minutes: 30 }); | ||
| }); | ||
| test('diff: should calculate the difference between two times', () => { | ||
| const endTime = new Time(5, 45, 'day'); | ||
| const difference = startTime.diff(endTime); | ||
| expect(difference).toEqual({ hours: 2, minutes: 30 }); | ||
| }); | ||
| test('diff: should calculate the shortest difference across the 24h wrap', () => { | ||
| const t1 = new Time(2, 0, 'night'); // 8 PM | ||
| const t2 = new Time(10, 0, 'night'); // 4 AM | ||
| const difference = t1.diff(t2); | ||
| expect(difference).toEqual({ hours: 8, minutes: 0 }); // 8 hours difference | ||
| }); | ||
| test('diff: should calculate the shortest difference across the 24h wrap', () => { | ||
| const t1 = new Time(2, 0, 'night'); // 8 PM | ||
| const t2 = new Time(10, 0, 'night'); // 4 AM | ||
| const difference = t1.diff(t2); | ||
| expect(difference).toEqual({ hours: 8, minutes: 0 }); // 8 hours difference | ||
| }); | ||
| test('add/subtract should throw on invalid duration', () => { | ||
| expect(() => startTime.add({ hours: 'two' })).toThrow(InvalidInputTypeError); | ||
| expect(() => startTime.subtract('one hour')).toThrow(InvalidTimeError); | ||
| }); | ||
| test('add/subtract should throw on invalid duration', () => { | ||
| expect(() => startTime.add({ hours: 'two' })).toThrow(InvalidInputTypeError); | ||
| expect(() => startTime.subtract('one hour')).toThrow(InvalidTimeError); | ||
| }); | ||
| }); | ||
| describe('Formatting', () => { | ||
| test('format: should format with default options (Geez)', () => { | ||
| const time = new Time(5, 30, 'day'); | ||
| expect(time.format()).toBe('፭:፴ ጠዋት'); | ||
| }); | ||
| //---------------------------------------------------------------- | ||
| // 5. Tests for Formatting | ||
| //---------------------------------------------------------------- | ||
| describe('Formatting', () => { | ||
| test('format: should format with default options (Geez)', () => { | ||
| const time = new Time(5, 30, 'day'); | ||
| expect(time.format()).toBe('፭:፴ ጠዋት'); | ||
| }); | ||
| test('format: should format with Arabic numerals', () => { | ||
| const time = new Time(5, 30, 'day'); | ||
| expect(time.format({ useGeez: false })).toBe('05:30 day'); | ||
| }); | ||
| test('format: should format with Arabic numerals', () => { | ||
| const time = new Time(5, 30, 'day'); | ||
| expect(time.format({ useGeez: false })).toBe('05:30 day'); | ||
| }); | ||
| test('format: should format without period label', () => { | ||
| const time = new Time(8, 15, 'night'); | ||
| expect(time.format({ useGeez: false, showPeriodLabel: false })).toBe('08:15'); | ||
| }); | ||
| test('format: should format without period label', () => { | ||
| const time = new Time(8, 15, 'night'); | ||
| expect(time.format({ useGeez: false, showPeriodLabel: false })).toBe('08:15'); | ||
| }); | ||
| test('format: should use a dash for zero minutes', () => { | ||
| const time = new Time(12, 0, 'day'); | ||
| expect(time.format({ useGeez: false, zeroAsDash: true })).toBe('12:_ day'); | ||
| }); | ||
| test('format: should use a dash for zero minutes', () => { | ||
| const time = new Time(12, 0, 'day'); | ||
| expect(time.format({ useGeez: false, zeroAsDash: true })).toBe('12:_ day'); | ||
| }); | ||
| }); | ||
| test('format: should format complex time correctly', () => { | ||
| const time = new Time(12, 0, 'night'); | ||
| expect(time.format()).toBe('፲፪:_ ማታ'); | ||
| }); | ||
| //---------------------------------------------------------------- | ||
| // 6. Tests for Kenat Class Time-Related Methods | ||
| //---------------------------------------------------------------- | ||
| describe('Kenat Time Methods', () => { | ||
| test('getCurrentTime returns a valid Time instance', () => { | ||
| const now = new Kenat(); | ||
| const ethTime = now.getCurrentTime(); | ||
| expect(ethTime).toBeInstanceOf(Time); | ||
| expect(ethTime).toHaveProperty('hour'); | ||
| expect(ethTime).toHaveProperty('minute'); | ||
| expect(ethTime).toHaveProperty('period'); | ||
| }); | ||
| }); | ||
| }); |
| import { Time } from '../../src/Time.js'; // Adjust path as needed | ||
| import { toGeez } from '../../src/geezConverter.js'; | ||
| const digitalClock = document.getElementById('digitalClock'); | ||
| const geezToggleBtn = document.getElementById('geezToggle'); | ||
| const canvas = document.getElementById('clockCanvas'); | ||
| const ctx = canvas.getContext('2d'); | ||
| const radius = canvas.height / 2; | ||
| ctx.translate(radius, radius); | ||
| let useGeez = true; | ||
| geezToggleBtn.addEventListener('click', () => { | ||
| useGeez = !useGeez; | ||
| geezToggleBtn.textContent = `Use Geez Numerals: ${useGeez ? 'ON' : 'OFF'}`; | ||
| drawClock(); | ||
| }); | ||
| function drawClock() { | ||
| drawFace(ctx, radius); | ||
| drawNumbers(ctx, radius); | ||
| drawTime(ctx, radius); | ||
| } | ||
| function drawFace(ctx, radius) { | ||
| ctx.beginPath(); | ||
| ctx.arc(0, 0, radius, 0, 2 * Math.PI); | ||
| ctx.fillStyle = 'white'; | ||
| ctx.fill(); | ||
| ctx.strokeStyle = '#333'; | ||
| ctx.lineWidth = radius * 0.05; | ||
| ctx.stroke(); | ||
| ctx.beginPath(); | ||
| ctx.arc(0, 0, radius * 0.05, 0, 2 * Math.PI); | ||
| ctx.fillStyle = '#333'; | ||
| ctx.fill(); | ||
| } | ||
| function drawNumbers(ctx, radius) { | ||
| const angIncrement = (2 * Math.PI) / 12; | ||
| ctx.font = `${radius * 0.15}px Arial`; | ||
| ctx.textBaseline = 'middle'; | ||
| ctx.textAlign = 'center'; | ||
| for (let num = 1; num <= 12; num++) { | ||
| let numeral = useGeez ? toGeez(num) : num.toString(); | ||
| let ang = num * angIncrement - Math.PI / 2; | ||
| let x = radius * 0.75 * Math.cos(ang); | ||
| let y = radius * 0.75 * Math.sin(ang); | ||
| ctx.fillStyle = '#000'; | ||
| ctx.fillText(numeral, x, y); | ||
| } | ||
| } | ||
| function drawTime(ctx, radius) { | ||
| const now = new Date(); | ||
| const time = Time.fromGregorian(now.getHours(), now.getMinutes()); | ||
| // Calculate angles for hands based on Ethiopian time | ||
| const hour = time.hour % 12; | ||
| const minute = time.minute; | ||
| const second = now.getSeconds(); | ||
| // Hour hand | ||
| const hourAngle = ((hour + minute / 60) * Math.PI) / 6 - Math.PI / 2; | ||
| drawHand(ctx, hourAngle, radius * 0.5, radius * 0.07); | ||
| // Minute hand | ||
| const minuteAngle = (minute * Math.PI) / 30 - Math.PI / 2; | ||
| drawHand(ctx, minuteAngle, radius * 0.75, radius * 0.05); | ||
| // Second hand | ||
| const secondAngle = (second * Math.PI) / 30 - Math.PI / 2; | ||
| drawHand(ctx, secondAngle, radius * 0.85, radius * 0.02, 'red'); | ||
| // Update digital clock | ||
| digitalClock.textContent = time.format({ useGeez, showPeriodLabel: true, zeroAsDash: false }); | ||
| } | ||
| function drawHand(ctx, pos, length, width, color = '#333') { | ||
| ctx.beginPath(); | ||
| ctx.lineWidth = width; | ||
| ctx.lineCap = 'round'; | ||
| ctx.strokeStyle = color; | ||
| ctx.moveTo(0, 0); | ||
| ctx.lineTo(length * Math.cos(pos), length * Math.sin(pos)); | ||
| ctx.stroke(); | ||
| } | ||
| function update() { | ||
| ctx.clearRect(-radius, -radius, canvas.width, canvas.height); | ||
| drawClock(); | ||
| requestAnimationFrame(update); | ||
| } | ||
| update(); |
| import { toGeez } from './geezConverter.js'; | ||
| import { PERIOD_LABELS } from './constants.js'; | ||
| import { validateNumericInputs, validateEthiopianTimeObject } from './utils.js'; | ||
| import { InvalidTimeError } from './errors/errorHandler.js'; | ||
| /** | ||
| * Converts a given hour and minute in standard time to Ethiopian time. | ||
| * | ||
| * @param {number} hour - The hour in standard time (0-23). | ||
| * @param {number} [minute=0] - The minute in standard time (0-59). | ||
| * @returns {{ hour: number, minute: number, period: 'day' | 'night' }} | ||
| * @throws {InvalidTimeError} If the Gregorian time is invalid. | ||
| */ | ||
| export function toEthiopianTime(hour, minute = 0) { | ||
| validateNumericInputs('toEthiopianTime', { hour, minute }); | ||
| if (hour < 0 || hour > 23) { | ||
| throw new InvalidTimeError(`Invalid Gregorian hour: ${hour}. Must be between 0 and 23.`); | ||
| } | ||
| if (minute < 0 || minute > 59) { | ||
| throw new InvalidTimeError(`Invalid minute: ${minute}. Must be between 0 and 59.`); | ||
| } | ||
| const period = hour >= 6 && hour < 18 ? 'day' : 'night'; | ||
| let ethHour = hour - 6; | ||
| if (ethHour < 0) ethHour += 12; | ||
| else ethHour = ethHour % 12; | ||
| ethHour = ethHour === 0 ? 12 : ethHour; | ||
| return { hour: ethHour, minute, period }; | ||
| } | ||
| /** | ||
| * Converts Ethiopian time to Gregorian 24-hour format. | ||
| * @param {number} ethHour - Ethiopian hour (1–12) | ||
| * @param {number} minute - Minute (0–59) | ||
| * @param {'day'|'night'} period | ||
| * @returns {{hour: number, minute: number}} | ||
| * @throws {InvalidTimeError} If the Ethiopian time is invalid. | ||
| */ | ||
| export function toGregorianTime(ethHour, minute = 0, period = 'day') { | ||
| validateNumericInputs('toGregorianTime', { ethHour, minute }); | ||
| if (ethHour < 1 || ethHour > 12) { | ||
| throw new InvalidTimeError(`Invalid Ethiopian hour: ${ethHour}. Must be between 1 and 12.`); | ||
| } | ||
| if (period !== 'day' && period !== 'night') { | ||
| throw new InvalidTimeError(`Invalid period: "${period}". Must be 'day' or 'night'.`); | ||
| } | ||
| let gregHour = ethHour % 12; | ||
| if (period === 'day') { | ||
| gregHour += 6; | ||
| } else { // 'night' | ||
| gregHour += 18; | ||
| } | ||
| gregHour = gregHour % 24; | ||
| return { hour: gregHour, minute }; | ||
| } | ||
| /** | ||
| * Adds a duration to an Ethiopian time. | ||
| * @param {{hour: number, minute?: number, period: 'day' | 'night'}} baseTime - The original Ethiopian time. | ||
| * @param {{hours?: number, minutes?: number}} duration - The time to add. | ||
| * @returns {{hour: number, minute: number, period: 'day' | 'night'}} | ||
| */ | ||
| export function addEthiopianTime(baseTime, duration) { | ||
| validateEthiopianTimeObject(baseTime, 'addEthiopianTime', 'baseTime'); | ||
| if (typeof duration !== 'object' || duration === null) { | ||
| throw new InvalidTimeError('Duration must be an object.'); | ||
| } | ||
| const { hours = 0, minutes = 0 } = duration; | ||
| validateNumericInputs('addEthiopianTime', { hours, minutes }); | ||
| const gregTime = toGregorianTime(baseTime.hour, baseTime.minute, baseTime.period); | ||
| let totalMinutes = gregTime.hour * 60 + gregTime.minute + (hours * 60) + minutes; | ||
| totalMinutes = ((totalMinutes % 1440) + 1440) % 1440; | ||
| const newHour = Math.floor(totalMinutes / 60); | ||
| const newMinute = totalMinutes % 60; | ||
| return toEthiopianTime(newHour, newMinute); | ||
| } | ||
| /** | ||
| * Subtracts a duration from an Ethiopian time. | ||
| * @param {{hour: number, minute?: number, period: 'day' | 'night'}} baseTime - The original Ethiopian time. | ||
| * @param {{hours?: number, minutes?: number}} duration - The time to subtract. | ||
| * @returns {{hour: number, minute: number, period: 'day' | 'night'}} | ||
| */ | ||
| export function subtractEthiopianTime(baseTime, duration) { | ||
| const { hours = 0, minutes = 0 } = duration; | ||
| return addEthiopianTime(baseTime, { hours: -hours, minutes: -minutes }); | ||
| } | ||
| /** | ||
| * Calculates the time difference between two Ethiopian times. | ||
| * @param {{hour: number, minute?: number, period: 'day' | 'night'}} time1 | ||
| * @param {{hour: number, minute?: number, period: 'day' | 'night'}} time2 | ||
| * @returns {{hours: number, minutes: number}} difference | ||
| */ | ||
| export function getTimeDifference(time1, time2) { | ||
| validateEthiopianTimeObject(time1, 'getTimeDifference', 'time1'); | ||
| validateEthiopianTimeObject(time2, 'getTimeDifference', 'time2'); | ||
| const t1 = toGregorianTime(time1.hour, time1.minute, time1.period); | ||
| const t2 = toGregorianTime(time2.hour, time2.minute, time2.period); | ||
| const total1 = t1.hour * 60 + t1.minute; | ||
| const total2 = t2.hour * 60 + t2.minute; | ||
| let diff = Math.abs(total1 - total2); | ||
| if (diff > 720) diff = 1440 - diff; | ||
| return { | ||
| hours: Math.floor(diff / 60), | ||
| minutes: diff % 60, | ||
| }; | ||
| } | ||
| /** | ||
| * Formats an Ethiopian time object. | ||
| * @param {Object} time - The Ethiopian time object { hour, minute, period }. | ||
| * @param {Object} [options] - Formatting options. | ||
| * @returns {string} The formatted time string. | ||
| */ | ||
| export function formatEthiopianTime(time, options = {}) { | ||
| validateEthiopianTimeObject(time, 'formatEthiopianTime', 'time'); | ||
| const defaultLang = options.useGeez === false ? 'english' : 'amharic'; | ||
| const { lang = defaultLang, useGeez = true, showPeriodLabel = true, zeroAsDash = true } = options; | ||
| const formatNum = (num) => { | ||
| if (useGeez) return toGeez(num); | ||
| return num.toString().padStart(2, '0'); | ||
| }; | ||
| const hourStr = formatNum(time.hour); | ||
| let minuteStr; | ||
| if (zeroAsDash && time.minute === 0) { | ||
| minuteStr = '_'; | ||
| } else { | ||
| minuteStr = useGeez ? toGeez(time.minute) : time.minute.toString().padStart(2, '0'); | ||
| } | ||
| let periodLabel = ''; | ||
| if (showPeriodLabel) { | ||
| if (lang === 'english') { | ||
| periodLabel = time.period; | ||
| } else { | ||
| const amharicLabels = { day: 'ጠዋት', night: 'ማታ' }; | ||
| periodLabel = amharicLabels[time.period]; | ||
| } | ||
| } | ||
| const label = periodLabel ? ` ${periodLabel}` : ''; | ||
| return `${hourStr}:${minuteStr}${label}`; | ||
| } |
| import { toEthiopianTime, toGregorianTime } from '../src/ethiopianTime.js'; | ||
| import { Kenat } from '../src/Kenat.js'; | ||
| describe('Time Conversion Tests', () => { | ||
| test.each([ | ||
| [0, 0, { hour: 6, minute: 0, period: 'night' }], | ||
| [6, 0, { hour: 12, minute: 0, period: 'day' }], | ||
| [7, 15, { hour: 1, minute: 15, period: 'day' }], | ||
| [13, 30, { hour: 7, minute: 30, period: 'day' }], | ||
| [18, 45, { hour: 12, minute: 45, period: 'night' }], | ||
| [20, 5, { hour: 2, minute: 5, period: 'night' }], | ||
| ])('toEthiopianTime(%i, %i)', (gHour, gMinute, expected) => { | ||
| expect(toEthiopianTime(gHour, gMinute)).toEqual(expected); | ||
| }); | ||
| test.each([ | ||
| [1, 15, 'day', { hour: 7, minute: 15 }], | ||
| [12, 0, 'day', { hour: 6, minute: 0 }], | ||
| [6, 0, 'night', { hour: 0, minute: 0 }], | ||
| [3, 30, 'night', { hour: 21, minute: 30 }], | ||
| ])('toGregorianTime(%i, %i, %s)', (eHour, minute, period, expected) => { | ||
| expect(toGregorianTime(eHour, minute, period)).toEqual(expected); | ||
| }); | ||
| test('throws on invalid Ethiopian hour', () => { | ||
| expect(() => toGregorianTime(0, 0, 'day')).toThrow(); | ||
| expect(() => toGregorianTime(13, 0, 'night')).toThrow(); | ||
| }); | ||
| }); | ||
| describe('Kenat Time Methods', () => { | ||
| test('getCurrentTime returns valid Ethiopian time object', () => { | ||
| const now = new Kenat(); | ||
| const ethTime = now.getCurrentTime(); | ||
| expect(ethTime).toHaveProperty('hour'); | ||
| expect(ethTime).toHaveProperty('minute'); | ||
| expect(ethTime).toHaveProperty('period'); | ||
| expect(typeof ethTime.hour).toBe('number'); | ||
| expect(typeof ethTime.minute).toBe('number'); | ||
| expect(['day', 'night']).toContain(ethTime.period); | ||
| }); | ||
| test('formatEthiopianTime formats in Amharic', () => { | ||
| const time = { hour: 2, minute: 5, period: 'day' }; | ||
| const formatted = Kenat.formatEthiopianTime(time, 'amharic'); | ||
| expect(formatted).toBe('02:05 ጠዋት'); | ||
| }); | ||
| test('formatEthiopianTime formats in English', () => { | ||
| const time = { hour: 10, minute: 45, period: 'night' }; | ||
| const formatted = Kenat.formatEthiopianTime(time, 'english'); | ||
| expect(formatted).toBe('10:45 night'); | ||
| }); | ||
| }); | ||
447334
0.46%36
-7.69%3275
-4.66%