feat: improved calculator

This commit is contained in:
SethBurkart123
2025-05-26 20:40:55 +10:00
parent 731ce42e74
commit 70a1ebf881
6 changed files with 291 additions and 60 deletions
+17
View File
@@ -0,0 +1,17 @@
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
};
+7 -1
View File
@@ -16,7 +16,10 @@
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b",
"zip": "bedframe zip"
"zip": "bedframe zip",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"targets": {
"prod": {
@@ -37,6 +40,7 @@
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.91",
"@crxjs/vite-plugin": "2.0.0-beta.32",
"@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -44,6 +48,7 @@
"dependency-cruiser": "^16.10.0",
"eslint": "9.22.0",
"glob": "^11.0.1",
"jest": "^29.7.0",
"mime-types": "^2.1.35",
"prettier": "^3.5.3",
"process": "^0.11.10",
@@ -52,6 +57,7 @@
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
"tailwindcss": "3",
"ts-jest": "^29.3.4",
"url": "^0.11.4"
},
"dependencies": {
@@ -1,7 +1,6 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { unitFullNames } from './unitMap';
import * as math from 'mathjs';
import { calculateExpression } from '../utils/calculator';
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
@@ -13,73 +12,32 @@
let isCalculating = $state(false);
let inputUnit = $state<string>('');
let outputUnit = $state<string>('');
function detectUnit(expression: string): string {
try {
const unit = math.unit(expression);
if (unit) {
// Get the base unit name
const unitStr = unit.formatUnits();
return unitFullNames[unitStr] || unitStr;
}
} catch (e) {
// Not a unit or invalid expression
}
return '';
}
let isPartial = $state(false);
// Process the input with debounce to avoid unnecessary calculations
const processInput = (input: string) => {
isCalculating = true;
try {
if (
!input.trim() ||
(input.trim().length <= 2 && !/\d/.test(input))
) {
result = null;
inputUnit = '';
outputUnit = '';
dispatch('hasResult', null);
return;
}
const calcResult = calculateExpression(input);
isCalculating = true;
// Let mathjs handle everything
const evaluated = math.evaluate(input.replace('**', '^'));
// Format the result
if (evaluated !== undefined) {
if (math.typeOf(evaluated) === 'Unit') {
// Handle unit conversion results
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(input);
outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') {
// Handle regular numbers
if (math.round(evaluated) === evaluated) {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
}
inputUnit = '';
outputUnit = '';
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = '';
outputUnit = '';
}
dispatch('hasResult', result);
if (calcResult.isValid) {
result = calcResult.result;
inputUnit = calcResult.inputUnit;
outputUnit = calcResult.outputUnit;
isPartial = calcResult.isPartial;
dispatch('hasResult', calcResult.result);
} else {
result = null;
inputUnit = '';
outputUnit = '';
isPartial = false;
dispatch('hasResult', null);
}
} catch (e) {
// If mathjs throws an error, this isn't a valid expression
result = null;
inputUnit = '';
outputUnit = '';
isPartial = false;
dispatch('hasResult', null);
} finally {
isCalculating = false;
@@ -96,7 +54,7 @@
</script>
{#if result !== null}
<div class="p-2">
<div class="">
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
@@ -124,7 +82,7 @@
{result}
</div>
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
{outputUnit || 'Result'}
{outputUnit || (isPartial ? 'Partial' : 'Result')}
</div>
</div>
{:else}
@@ -0,0 +1,223 @@
import * as math from 'mathjs';
import { unitFullNames } from './unitMap';
export interface CalculatorResult {
result: string | null;
isValid: boolean;
isPartial: boolean;
inputUnit: string;
outputUnit: string;
error?: string;
}
const expandedMath = math.create(math.all);
expandedMath.import({
five: 5,
ten: 10,
three: 3,
four: 4,
eight: 8,
sixteen: 16,
twenty: 20,
twentyfive: 25,
fifty: 50,
hundred: 100,
plus: (a: number, b: number) => a + b,
minus: (a: number, b: number) => a - b,
times: (a: number, b: number) => a * b,
divided: (a: number, b: number) => a / b,
power: (a: number, b: number) => Math.pow(a, b),
half: (a: number) => a / 2,
double: (a: number) => a * 2,
quarter: (a: number) => a / 4,
// String functions
length: (str: string) => str.length,
concat: (...args: string[]) => args.join(''),
uppercase: (str: string) => str.toUpperCase(),
lowercase: (str: string) => str.toLowerCase(),
substr: (str: string, start: number, length: number) => str.substr(start, length),
// Random functions
randomInt: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min,
// Comparison and Boolean operations
and: (a: boolean, b: boolean) => a && b,
or: (a: boolean, b: boolean) => a || b,
not: (a: boolean) => !a,
// Combinatorics
permutations: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nPr: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nCr: (n: number, r: number) => expandedMath.combinations(n, r),
// Number theory
gcd: (a: number, b: number) => expandedMath.gcd(a, b),
lcm: (a: number, b: number) => expandedMath.lcm(a, b),
// Precision functions
precision: (num: number, digits: number) => parseFloat(num.toPrecision(digits)),
fix: (num: number, digits: number) => parseFloat(num.toFixed(digits)),
// Percentage operations
percent: (value: number) => value / 100,
// Financial operations
compound: (principal: number, rate: number, time: number) => principal * Math.pow(1 + rate, time),
}, { override: true });
function detectUnit(expression: string): string {
try {
const unit = expandedMath.unit(expression);
if (unit) {
const unitStr = unit.formatUnits();
return unitFullNames[unitStr] || unitStr;
}
} catch (e) {
// Not a unit or invalid expression
}
return '';
}
function isLikelyMathExpression(input: string): boolean {
const trimmed = input.trim();
// Must contain at least one digit or mathematical operator
if (!/[\d+\-*/^()=.]/.test(trimmed)) {
return false;
}
// Check for common non-math words that shouldn't trigger calculator
const nonMathWords = ['abs', 'function', 'class', 'const', 'let', 'var', 'if', 'else', 'while', 'for', 'return', 'import', 'export'];
const words = trimmed.toLowerCase().split(/\s+/);
// If it's just a single non-math word, skip it
if (words.length === 1 && nonMathWords.includes(words[0]) && !/\d/.test(trimmed)) {
return false;
}
// Must have some mathematical content
const mathPattern = /(\d+\.?\d*|\+|\-|\*|\/|\^|\(|\)|sin|cos|tan|log|sqrt|pi|e|=)/i;
return mathPattern.test(trimmed);
}
function tryCompleteExpression(expression: string): string | null {
const trimmed = expression.trim();
// Common patterns for incomplete expressions
const incompletePatterns = [
/[\+\-\*\/\^]\s*$/, // ends with operator
/\(\s*$/, // ends with opening parenthesis
/[\+\-\*\/\^]\s*\(/, // operator followed by opening parenthesis
];
for (const pattern of incompletePatterns) {
if (pattern.test(trimmed)) {
// Try to evaluate what we have so far by removing the incomplete part
let partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim();
// Handle cases like "4 + 3 *" -> evaluate "4 + 3"
if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) {
try {
const result = expandedMath.evaluate(partial);
if (typeof result === 'number' && !isNaN(result)) {
return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
}
} catch (e) {
// Continue to other attempts
}
}
}
}
return null;
}
export function calculateExpression(input: string): CalculatorResult {
const trimmed = input.trim();
// Early exit for empty or very short inputs
if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) {
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
};
}
// Check if this looks like a math expression at all
if (!isLikelyMathExpression(trimmed)) {
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
};
}
try {
// First try to evaluate the expression as-is
const evaluated = expandedMath.evaluate(trimmed.replace('**', '^'));
if (evaluated !== undefined) {
let result: string;
let inputUnit = '';
let outputUnit = '';
if (math.typeOf(evaluated) === 'Unit') {
// Handle unit conversion results
result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(trimmed);
outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') {
// Handle regular numbers
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
}
return {
result,
isValid: true,
isPartial: false,
inputUnit,
outputUnit,
};
}
} catch (error) {
// Try to handle incomplete expressions
const partialResult = tryCompleteExpression(trimmed);
if (partialResult) {
return {
result: partialResult,
isValid: true,
isPartial: true,
inputUnit: '',
outputUnit: '',
};
}
// If it still looks like math but failed, return the error
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
error: error instanceof Error ? error.message : 'Invalid expression',
};
}
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
};
}
@@ -177,7 +177,7 @@ export const unitFullNames: Record<string, string> = {
// --- Currency ---
USD: "US Dollars",
EUR: "Euros",
EUR: "Euros",
GBP: "British Pounds",
AUD: "Australian Dollars",
CAD: "Canadian Dollars",
@@ -190,4 +190,31 @@ export const unitFullNames: Record<string, string> = {
NOK: "Norwegian Krone",
SGD: "Singapore Dollars",
HKD: "Hong Kong Dollars",
KRW: "South Korean Won",
MXN: "Mexican Peso",
BRL: "Brazilian Real",
RUB: "Russian Ruble",
TRY: "Turkish Lira",
ZAR: "South African Rand",
PLN: "Polish Zloty",
THB: "Thai Baht",
DKK: "Danish Krone",
CZK: "Czech Koruna",
HUF: "Hungarian Forint",
ILS: "Israeli Shekel",
PHP: "Philippine Peso",
TWD: "Taiwan Dollar",
MYR: "Malaysian Ringgit",
// --- Cryptocurrencies ---
BTC: "Bitcoin",
ETH: "Ethereum",
XRP: "Ripple",
LTC: "Litecoin",
ADA: "Cardano",
DOT: "Polkadot",
SOL: "Solana",
MATIC: "Polygon",
AVAX: "Avalanche",
UNI: "Uniswap",
};
+1 -1
View File
@@ -28,7 +28,7 @@
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "node"]
"types": ["vite/client", "node", "jest"]
},
"include": [
"src/**/*.ts",