UNPKG

kenat

Version:

A JavaScript library for the Ethiopian calendar with date and time support.

264 lines (226 loc) 10.2 kB
import { toGeez, toArabic } from './geezConverter.js'; import { PERIOD_LABELS } from './constants.js'; import { InvalidTimeError } from './errors/errorHandler.js'; import { validateNumericInputs } from './utils.js'; export class Time { /** * Constructs a Time instance representing an Ethiopian time. * @param {number} hour - The Ethiopian hour (1-12). * @param {number} [minute=0] - The minute (0-59). * @param {string} [period='day'] - The period ('day' or 'night'). * @throws {InvalidTimeError} If any time component is invalid. */ constructor(hour, minute = 0, period = 'day') { validateNumericInputs('Time.constructor', { hour, minute }); 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; } /** * Creates a Time instance from a Gregorian 24-hour time. * @param {number} hour - The Gregorian hour (0-23). * @param {number} [minute=0] - The minute (0-59). * @returns {Time} A new Time instance. * @throws {InvalidTimeError} If the Gregorian time is invalid. */ static fromGregorian(hour, minute = 0) { validateNumericInputs('Time.fromGregorian', { 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.`); } // Normalize Gregorian hour to an Ethiopian base (where 6 AM is 0) 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); } /** * Converts the Ethiopian time to Gregorian 24-hour format. * @returns {{hour: number, minute: number}} */ toGregorian() { // Convert Ethiopian 1-12 hour to a 0-11 offset, where 12 o'clock is 0. let gregHour = this.hour % 12; if (this.period === 'day') { gregHour += 6; } else { // 'night' gregHour += 18; } // Handle the 24-hour wrap-around (e.g., 6 night becomes 24, which should be 0) gregHour = gregHour % 24; return { hour: gregHour, minute: this.minute }; } /** * 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. * @param {{hours?: number, minutes?: number}} duration - Object with hours and/or minutes to add. * @returns {Time} A new Time instance with the added duration. */ add(duration) { if (typeof duration !== 'object' || duration === null) { throw new InvalidTimeError('Duration must be an object.'); } const { hours = 0, minutes = 0 } = duration; validateNumericInputs('Time.add', { hours, minutes }); const greg = this.toGregorian(); let totalMinutes = greg.hour * 60 + greg.minute + hours * 60 + minutes; 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); } /** * Subtracts a duration from the current time. * @param {{hours?: number, minutes?: number}} duration - Object with hours and/or minutes to subtract. * @returns {Time} A new Time instance with the subtracted duration. */ subtract(duration) { if (typeof duration !== 'object' || duration === null) { throw new InvalidTimeError('Duration must be an object.'); } const { hours = 0, minutes = 0 } = duration; return this.add({ hours: -hours, minutes: -minutes }); } /** * Calculates the difference between this time and another. * @param {Time} otherTime - Another Time instance to compare against. * @returns {{hours: number, minutes: number}} An object with the absolute difference. */ diff(otherTime) { if (!(otherTime instanceof Time)) { throw new InvalidTimeError('Can only compare with another Time instance.'); } const t1 = this.toGregorian(); const t2 = otherTime.toGregorian(); const totalMinutes1 = t1.hour * 60 + t1.minute; const totalMinutes2 = t2.hour * 60 + t2.minute; let diff = Math.abs(totalMinutes1 - totalMinutes2); // Time wraps in a 24h cycle, so find the shortest path if (diff > 720) diff = 1440 - diff; return { hours: Math.floor(diff / 60), minutes: diff % 60, }; } /** * Formats the time as a string. * @param {Object} [options] - Formatting options. * @param {string} [options.lang] - The language for the period label. Defaults to 'english' if useGeez is false, otherwise 'amharic'. * @param {boolean} [options.useGeez=true] - Whether to use Ge'ez numerals. * @param {boolean} [options.showPeriodLabel=true] - Whether to show the period label. * @param {boolean} [options.zeroAsDash=true] - Whether to represent zero minutes as a dash. * @returns {string} The formatted time string. */ format(options = {}) { // If useGeez is explicitly false, the default language should be English. 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') { // 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]; } } const label = periodLabel ? ` ${periodLabel}` : ''; return `${hourStr}:${minuteStr}${label}`; } }