🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

kenat

Package Overview
Dependencies
Maintainers
1
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

kenat - npm Package Compare versions

Comparing version
2.0.2
to
2.0.3
+264
-39
examples/vanilla/ethiopian-clock.html
<!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 */

}
}
}

@@ -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 @@

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 @@ /**

@@ -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}`;

/* /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');
});
});