feat(conversion): add unit and currency conversion systems with UnitConverter, UnitDefinitions, CurrencyConverter, and ExchangeRateService
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Currency converter for the scientific calculator
|
||||
* Handles conversion between currencies using predefined exchange rates.
|
||||
* Uses ExchangeRateService to get the rates.
|
||||
*/
|
||||
|
||||
const ExchangeRateService = require('./ExchangeRateService');
|
||||
|
||||
/**
|
||||
* Converts a value from one currency to another.
|
||||
* @param {number} value - The value to convert.
|
||||
* @param {string} fromCurrency - The currency code to convert from (e.g., 'USD').
|
||||
* @param {string} toCurrency - The currency code to convert to (e.g., 'EUR').
|
||||
* @returns {{value: number, error: string|null}} - Result object with either the converted value or an error.
|
||||
*/
|
||||
function convert(value, fromCurrency, toCurrency) {
|
||||
// Validate input value
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return { value: null, error: 'Invalid value: must be a number' };
|
||||
}
|
||||
|
||||
// Validate currency codes (must be 3 uppercase letters)
|
||||
const currencyCodeRegex = /^[A-Z]{3}$/;
|
||||
if (!currencyCodeRegex.test(fromCurrency)) {
|
||||
return { value: null, error: `Invalid fromCurrency code: ${fromCurrency}. Must be a 3-letter uppercase code (e.g., USD).` };
|
||||
}
|
||||
if (!currencyCodeRegex.test(toCurrency)) {
|
||||
return { value: null, error: `Invalid toCurrency code: ${toCurrency}. Must be a 3-letter uppercase code (e.g., EUR).` };
|
||||
}
|
||||
|
||||
// Get the exchange rates
|
||||
const rates = ExchangeRateService.getRates();
|
||||
const fromRate = rates[fromCurrency];
|
||||
const toRate = rates[toCurrency];
|
||||
|
||||
if (fromRate === undefined) {
|
||||
return { value: null, error: `Exchange rate for ${fromCurrency} not available.` };
|
||||
}
|
||||
if (toRate === undefined) {
|
||||
return { value: null, error: `Exchange rate for ${toCurrency} not available.` };
|
||||
}
|
||||
|
||||
// Avoid division by zero
|
||||
if (fromRate === 0) {
|
||||
return { value: null, error: 'Cannot convert from a currency with zero rate.' };
|
||||
}
|
||||
|
||||
// Conversion: value in target currency = value * (toRate / fromRate)
|
||||
// Explanation: If we have 1 USD = 0.9 EUR, then rates: { USD: 1, EUR: 0.9 } (assuming base is USD?).
|
||||
// Actually, we store rates as: 1 unit of currency = rate units of base currency.
|
||||
// Let's define: we have a base currency (e.g., USD) and rates are the value of 1 unit of that currency in base.
|
||||
// Then to convert from currency A to currency B:
|
||||
// value_in_base = value * rate[A]
|
||||
// value_in_target = value_in_base / rate[B]
|
||||
// So: result = value * (rate[A] / rate[B])
|
||||
// However, note that in our ExchangeRateService, we store the rate as the amount of base currency per unit of the currency.
|
||||
// For example, if base is USD, then rate for EUR is 0.9 (meaning 1 EUR = 0.9 USD).
|
||||
// Then to convert 100 EUR to USD: 100 * 0.9 = 90 USD.
|
||||
// To convert 100 USD to EUR: 100 * (1 / 0.9) = 111.11 EUR.
|
||||
// So the formula is: result = value * (rate[from] / rate[to])
|
||||
|
||||
const result = value * (fromRate / toRate);
|
||||
return { value: result, error: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
convert,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Exchange rate service for the scientific calculator
|
||||
* Manages currency exchange rates with timestamps in localStorage.
|
||||
* Designed to run in a browser environment.
|
||||
*/
|
||||
|
||||
// Default exchange rates (1 unit of currency = X USD)
|
||||
// These are example rates and should be updated by the user
|
||||
const DEFAULT_RATES = {
|
||||
USD: 1.0, // US Dollar
|
||||
EUR: 0.9, // Euro
|
||||
GBP: 0.75, // British Pound
|
||||
JPY: 0.007, // Japanese Yen
|
||||
CAD: 0.75, // Canadian Dollar
|
||||
AUD: 0.7, // Australian Dollar
|
||||
INR: 0.012 // Indian Rupee
|
||||
};
|
||||
|
||||
// How long until rates are considered stale (in milliseconds)
|
||||
// 24 hours = 24 * 60 * 60 * 1000 = 86,400,000 ms
|
||||
const STALE_THRESHOLD = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Key for storing rates in localStorage
|
||||
const STORAGE_KEY = 'calculator_exchange_rates';
|
||||
|
||||
/**
|
||||
* Loads the exchange rates object from localStorage.
|
||||
* @returns {Object|null} The rates object (with rates and timestamp) or null if not found/invalid.
|
||||
*/
|
||||
function _loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(stored);
|
||||
// Validate the structure
|
||||
if (parsed && typeof parsed === 'object' &&
|
||||
parsed.rates && typeof parsed.rates === 'object' &&
|
||||
typeof parsed.timestamp === 'number') {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
// If there's any error (e.g., invalid JSON), return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the exchange rates object to localStorage.
|
||||
* @param {Object} ratesObj - The rates object to save (should have rates and timestamp).
|
||||
*/
|
||||
function _saveToStorage(ratesObj) {
|
||||
try {
|
||||
const toStore = {
|
||||
rates: ratesObj.rates,
|
||||
timestamp: ratesObj.timestamp
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
|
||||
} catch (e) {
|
||||
// In a real app, you might want to log this error
|
||||
console.warn('Failed to save exchange rates to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the exchange rate for a given currency.
|
||||
* @param {string} currency - The currency code (e.g., 'USD').
|
||||
* @returns {number|null} The rate (amount of USD per unit of currency) or null if not found.
|
||||
*/
|
||||
function getRate(currency) {
|
||||
if (typeof currency !== 'string' || currency.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stored = _loadFromStorage();
|
||||
let rates = null;
|
||||
|
||||
if (stored === null) {
|
||||
// No stored rates, use defaults and save them
|
||||
rates = { ...DEFAULT_RATES };
|
||||
_saveToStorage({ rates, timestamp: Date.now() });
|
||||
} else {
|
||||
rates = stored.rates;
|
||||
}
|
||||
|
||||
return rates[currency] !== undefined ? rates[currency] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the exchange rate for a given currency.
|
||||
* @param {string} currency - The currency code (e.g., 'USD').
|
||||
* @param {number} rate - The rate (amount of USD per unit of currency).
|
||||
*/
|
||||
function setRate(currency, rate) {
|
||||
if (typeof currency !== 'string' || currency.length === 0 ||
|
||||
typeof rate !== 'number' || isNaN(rate)) {
|
||||
return; // Invalid input, do nothing
|
||||
}
|
||||
|
||||
const stored = _loadFromStorage();
|
||||
let rates = {};
|
||||
|
||||
if (stored !== null) {
|
||||
rates = { ...stored.rates };
|
||||
} else {
|
||||
rates = { ...DEFAULT_RATES }; // Start with defaults if none stored
|
||||
}
|
||||
|
||||
rates[currency] = rate;
|
||||
_saveToStorage({ rates, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a copy of the current exchange rates object.
|
||||
* @returns {Object} A copy of the rates object (currency code -> rate).
|
||||
*/
|
||||
function getRates() {
|
||||
const stored = _loadFromStorage();
|
||||
if (stored === null) {
|
||||
// Return a copy of the default rates
|
||||
return { ...DEFAULT_RATES };
|
||||
}
|
||||
// Return a copy of the stored rates
|
||||
return { ...stored.rates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the stored rates are considered stale.
|
||||
* @returns {boolean} True if no rates are stored or if they are older than STALE_THRESHOLD.
|
||||
*/
|
||||
function isRateStale() {
|
||||
const stored = _loadFromStorage();
|
||||
if (stored === null) {
|
||||
return true; // No rates stored
|
||||
}
|
||||
const age = Date.now() - stored.timestamp;
|
||||
return age > STALE_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp of the last update (in milliseconds since epoch).
|
||||
* @returns {number|null} The timestamp or null if no rates stored.
|
||||
*/
|
||||
function getLastUpdatedTimestamp() {
|
||||
const stored = _loadFromStorage();
|
||||
return stored !== null ? stored.timestamp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a human-readable string indicating how long ago the rates were updated.
|
||||
* @returns {string} A string like "Updated 5 minutes ago" or "Never updated".
|
||||
*/
|
||||
function getTimeSinceUpdate() {
|
||||
const timestamp = getLastUpdatedTimestamp();
|
||||
if (timestamp === null) {
|
||||
return 'Never updated';
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - timestamp;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHours = Math.round(diffMin / 60);
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
|
||||
if (diffSec < 5) {
|
||||
return 'Just now';
|
||||
} else if (diffSec < 60) {
|
||||
return `${diffSec} seconds ago`;
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minutes ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hours ago`;
|
||||
} else {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the exchange rates to the default values.
|
||||
* This is useful for testing or if the user wants to revert to defaults.
|
||||
*/
|
||||
function resetToDefaults() {
|
||||
_saveToStorage({
|
||||
rates: { ...DEFAULT_RATES },
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRate,
|
||||
setRate,
|
||||
getRates,
|
||||
isRateStale,
|
||||
getLastUpdatedTimestamp,
|
||||
getTimeSinceUpdate,
|
||||
resetToDefaults
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Unit converter for the scientific calculator
|
||||
* Handles conversion between units within the same category.
|
||||
* Uses predefined conversion factors from UnitDefinitions.
|
||||
*/
|
||||
|
||||
const UnitDefinitions = require('./UnitDefinitions');
|
||||
|
||||
/**
|
||||
* Converts a value from one unit to another within the same category.
|
||||
* @param {number} value - The value to convert.
|
||||
* @param {string} fromUnit - The unit to convert from.
|
||||
* @param {string} toUnit - The unit to convert to.
|
||||
* @param {string} category - The category of the units (optional, will be inferred if not provided).
|
||||
* @returns {{value: number, error: string|null}} - Result object with either the converted value or an error.
|
||||
*/
|
||||
function convert(value, fromUnit, toUnit, category) {
|
||||
// Validate input value
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return { value: null, error: 'Invalid value: must be a number' };
|
||||
}
|
||||
|
||||
// Validate unit strings
|
||||
if (typeof fromUnit !== 'string' || typeof toUnit !== 'string') {
|
||||
return { value: null, error: 'Invalid unit: must be a string' };
|
||||
}
|
||||
|
||||
// Determine category if not provided
|
||||
let cat = category;
|
||||
if (!cat) {
|
||||
cat = getCategoryForUnit(fromUnit);
|
||||
if (!cat || getCategoryForUnit(toUnit) !== cat) {
|
||||
return { value: null, error: 'Units are not in the same category or are invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get the unit definitions for the category
|
||||
const categoryDefs = UnitDefinitions[cat];
|
||||
if (!categoryDefs) {
|
||||
return { value: null, error: `Unknown category: ${cat}` };
|
||||
}
|
||||
|
||||
// Get the definitions for the specific units
|
||||
const fromDef = categoryDefs[fromUnit];
|
||||
const toDef = categoryDefs[toUnit];
|
||||
|
||||
if (!fromDef || !toDef) {
|
||||
return { value: null, error: `One or both units (${fromUnit}, ${toUnit}) not found in category ${cat}` };
|
||||
}
|
||||
|
||||
// Handle temperature conversions (which have a different structure)
|
||||
if (fromDef.type === 'temperature' || toDef.type === 'temperature') {
|
||||
return convertTemperature(value, fromDef, toDef);
|
||||
}
|
||||
|
||||
// For non-temperature units, we use the factor method
|
||||
// The factor is the multiplier to get to the base unit.
|
||||
// So: value in base unit = value * fromFactor
|
||||
// Then: value in target unit = (value in base unit) / toFactor
|
||||
// Therefore: result = value * (fromFactor / toFactor)
|
||||
const fromFactor = fromDef;
|
||||
const toFactor = toDef;
|
||||
|
||||
// Avoid division by zero
|
||||
if (toFactor === 0) {
|
||||
return { value: null, error: 'Cannot convert to a unit with zero factor' };
|
||||
}
|
||||
|
||||
const result = value * (fromFactor / toFactor);
|
||||
return { value: result, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert between temperature units.
|
||||
* @param {number} value - The value to convert.
|
||||
* @param {Object} fromDef - The definition of the source unit (from UnitDefinitions).
|
||||
* @param {Object} toDef - The definition of the target unit (from UnitDefinitions).
|
||||
* @returns {{value: number, error: string|null}} - Result object.
|
||||
*/
|
||||
function convertTemperature(value, fromDef, toDef) {
|
||||
// First, convert the input value to Kelvin
|
||||
let kelvin;
|
||||
if (fromDef.type === 'temperature') {
|
||||
if (fromDef.offset !== undefined) {
|
||||
// For Celsius and Fahrenheit, we have an offset
|
||||
if (fromDef.factor !== undefined) {
|
||||
// Fahrenheit: K = (F + 459.67) * 5/9
|
||||
kelvin = (value + fromDef.offset) * fromDef.factor;
|
||||
} else {
|
||||
// Celsius: K = C + 273.15
|
||||
kelvin = value + fromDef.offset;
|
||||
}
|
||||
} else {
|
||||
// Kelvin: already in Kelvin
|
||||
kelvin = value;
|
||||
}
|
||||
} else {
|
||||
// This should not happen if we check the type correctly
|
||||
return { value: null, error: 'Internal error: invalid temperature unit definition' };
|
||||
}
|
||||
|
||||
// Now convert from Kelvin to the target unit
|
||||
let result;
|
||||
if (toDef.type === 'temperature') {
|
||||
if (toDef.offset !== undefined) {
|
||||
if (toDef.factor !== undefined) {
|
||||
// Fahrenheit: F = (K * 9/5) - 459.67
|
||||
result = (kelvin / toDef.factor) - toDef.offset;
|
||||
} else {
|
||||
// Celsius: C = K - 273.15
|
||||
result = kelvin - toDef.offset;
|
||||
}
|
||||
} else {
|
||||
// Kelvin: already in Kelvin
|
||||
result = kelvin;
|
||||
}
|
||||
} else {
|
||||
// This should not happen
|
||||
return { value: null, error: 'Internal error: invalid temperature unit definition' };
|
||||
}
|
||||
|
||||
return { value: result, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine the category of a unit.
|
||||
* @param {string} unit - The unit string.
|
||||
* @returns {string|null} The category name, or null if not found.
|
||||
*/
|
||||
function getCategoryForUnit(unit) {
|
||||
for (const category in UnitDefinitions) {
|
||||
if (UnitDefinitions[category].hasOwnProperty(unit)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available units and their categories.
|
||||
* @returns {Object} An object mapping categories to arrays of unit strings.
|
||||
*/
|
||||
function getAllUnits() {
|
||||
const result = {};
|
||||
for (const category in UnitDefinitions) {
|
||||
result[category] = Object.keys(UnitDefinitions[category]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
convert,
|
||||
getAllUnits,
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Unit definitions and conversion factors for the scientific calculator
|
||||
* Each unit is defined by its factor relative to a base unit in its category.
|
||||
*/
|
||||
|
||||
const UnitDefinitions = {
|
||||
// Electrical
|
||||
electrical: {
|
||||
// Voltage
|
||||
V: 1, // Volt
|
||||
mV: 1e-3, // Millivolt
|
||||
kV: 1e3, // Kilovolt
|
||||
μV: 1e-6, // Microvolt
|
||||
// Current
|
||||
A: 1, // Ampere
|
||||
mA: 1e-3, // Milliampere
|
||||
μA: 1e-6, // Microampere
|
||||
nA: 1e-9, // Nanoampere
|
||||
// Resistance
|
||||
Ω: 1, // Ohm
|
||||
kΩ: 1e3, // Kiloohm
|
||||
MΩ: 1e6, // Megaohm
|
||||
// Capacitance
|
||||
F: 1, // Farad
|
||||
μF: 1e-6, // Microfarad
|
||||
pF: 1e-12, // Picofarad
|
||||
nF: 1e-9, // Nanofarad
|
||||
// Power
|
||||
W: 1, // Watt
|
||||
kW: 1e3, // Kilowatt
|
||||
MW: 1e6, // Megawatt
|
||||
// Energy
|
||||
J: 1, // Joule
|
||||
kWh: 3.6e6, // Kilowatt-hour
|
||||
Wh: 3600, // Watt-hour
|
||||
// Charge
|
||||
C: 1, // Coulomb
|
||||
Ah: 3600, // Ampere-hour
|
||||
mAh: 3.6, // Milliampere-hour
|
||||
},
|
||||
|
||||
// Mechanical
|
||||
mechanical: {
|
||||
// Force
|
||||
N: 1, // Newton
|
||||
kN: 1e3, // Kilonewton
|
||||
// Mass/force
|
||||
kgf: 9.80665, // Kilogram-force (standard gravity)
|
||||
lbf: 4.44822, // Pound-force
|
||||
// Pressure
|
||||
Pa: 1, // Pascal
|
||||
kPa: 1e3, // Kilopascal
|
||||
MPa: 1e6, // Megapascal
|
||||
bar: 1e5, // Bar
|
||||
psi: 6894.76, // Pound per square inch
|
||||
// Velocity
|
||||
'm/s': 1, // Meter per second
|
||||
'km/h': 1/3.6, // Kilometer per hour
|
||||
mph: 0.44704, // Mile per hour
|
||||
// Acceleration
|
||||
'm/s²': 1, // Meter per second squared
|
||||
g: 9.80665, // Standard gravity
|
||||
// Torque
|
||||
'Nm': 1, // Newton meter
|
||||
'ft·lb': 1.35582, // Foot-pound
|
||||
// Frequency
|
||||
Hz: 1, // Hertz
|
||||
RPM: 1/60, // Revolutions per minute (to Hz)
|
||||
},
|
||||
|
||||
// Thermal
|
||||
thermal: {
|
||||
// Temperature (special handling required)
|
||||
K: { type: 'temperature', offset: 0 }, // Kelvin
|
||||
°C: { type: 'temperature', offset: 273.15 }, // Celsius
|
||||
°F: { type: 'temperature', offset: 459.67, factor: 5/9 }, // Fahrenheit
|
||||
// Specific heat
|
||||
'J/(kg·K)': 1, // Joule per kilogram Kelvin
|
||||
'J/(g·°C)': 1, // Same as above (since 1 g = 0.001 kg and 1°C change = 1 K change)
|
||||
// Thermal conductivity
|
||||
'W/(m·K)': 1, // Watt per meter Kelvin
|
||||
// Power (same as electrical)
|
||||
W: 1, // Watt
|
||||
kW: 1e3, // Kilowatt
|
||||
},
|
||||
|
||||
// Volume
|
||||
volume: {
|
||||
L: 1, // Liter
|
||||
mL: 1e-3, // Milliliter
|
||||
μL: 1e-6, // Microliter
|
||||
cm³: 1e-3, // Cubic centimeter (1 mL)
|
||||
in³: 1.63871e-5, // Cubic inch
|
||||
ft³: 0.0283168, // Cubic foot
|
||||
tsp: 4.92892e-3, // Teaspoon
|
||||
tbsp: 1.47868e-2, // Tablespoon
|
||||
'fl oz': 2.95735e-2, // Fluid ounce
|
||||
cup: 2.36588e-1, // Cup
|
||||
pt: 4.73176e-1, // Pint
|
||||
qt: 0.946353, // Quart
|
||||
gal: 3.78541, // Gallon
|
||||
},
|
||||
|
||||
// Mass
|
||||
mass: {
|
||||
kg: 1, // Kilogram
|
||||
g: 1e-3, // Gram
|
||||
mg: 1e-6, // Milligram
|
||||
μg: 1e-9, // Microgram
|
||||
lb: 0.453592, // Pound
|
||||
oz: 0.0283495, // Ounce
|
||||
},
|
||||
|
||||
// Time
|
||||
time: {
|
||||
s: 1, // Second
|
||||
ms: 1e-3, // Millisecond
|
||||
μs: 1e-6, // Microsecond
|
||||
ns: 1e-9, // Nanosecond
|
||||
min: 60, // Minute
|
||||
h: 3600, // Hour
|
||||
d: 86400, // Day
|
||||
yr: 3.15576e7, // Year (Julian year)
|
||||
},
|
||||
|
||||
// Frequency (note: Hz is also in mechanical, but we'll keep it here for consistency)
|
||||
frequency: {
|
||||
Hz: 1, // Hertz
|
||||
kHz: 1e3, // Kilohertz
|
||||
MHz: 1e6, // Megahertz
|
||||
GHz: 1e9, // Gigahertz
|
||||
RPM: 1/60, // Revolutions per minute (to Hz)
|
||||
},
|
||||
|
||||
// Data
|
||||
data: {
|
||||
bit: 1, // Bit
|
||||
byte: 8, // Byte
|
||||
KB: 8e3, // Kilobyte (SI)
|
||||
MB: 8e6, // Megabyte (SI)
|
||||
GB: 8e9, // Gigabyte (SI)
|
||||
TB: 8e12, // Terabyte (SI)
|
||||
// Note: For binary prefixes (KiB, MiB, etc.), the user can specify if needed.
|
||||
// We are using SI (decimal) prefixes as default.
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = UnitDefinitions;
|
||||
Reference in New Issue
Block a user