From 97f9a954154fe70fc64a70a2e0e848f44dea1472 Mon Sep 17 00:00:00 2001 From: whilb Date: Sat, 16 Aug 2025 17:54:12 -0700 Subject: [PATCH] init --- .gitignore | 7 + Makefile | 140 ++++++ dev_server.py | 24 + infra/backend.tf | 45 ++ infra/main.tf | 171 +++++++ infra/terraform.tfvars | 4 + infra/variables.tf | 24 + infra/versions.tf | 28 ++ public/calculators/bandwidth.js | 21 + public/calculators/currency.js | 399 +++++++++++++++ public/calculators/interest.js | 50 ++ public/calculators/nmea.js | 22 + public/calculators/raid.js | 32 ++ public/css/styles.css | 207 ++++++++ public/index.html | 30 ++ public/js/app.js | 163 ++++++ public/js/util.js | 206 ++++++++ requirements.txt | 12 + tests/conftest.py | 111 ++++ tests/test_calculators.py | 404 +++++++++++++++ tests/test_converter.py | 863 ++++++++++++++++++++++++++++++++ 21 files changed, 2963 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100755 dev_server.py create mode 100644 infra/backend.tf create mode 100644 infra/main.tf create mode 100644 infra/terraform.tfvars create mode 100644 infra/variables.tf create mode 100644 infra/versions.tf create mode 100644 public/calculators/bandwidth.js create mode 100644 public/calculators/currency.js create mode 100644 public/calculators/interest.js create mode 100644 public/calculators/nmea.js create mode 100644 public/calculators/raid.js create mode 100644 public/css/styles.css create mode 100644 public/index.html create mode 100644 public/js/app.js create mode 100644 public/js/util.js create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_calculators.py create mode 100644 tests/test_converter.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ca3bdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +tags +tags.lock +tags.temp +tests/__pycache__ +infra/.terraform +infra/.terraform.lock.hcl +venv diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..347c411 --- /dev/null +++ b/Makefile @@ -0,0 +1,140 @@ +.PHONY: help init plan apply destroy output clean backend-setup setup-backend deploy-frontend dev test test-local install-deps lint format + +help: ## Show this help message + @echo "Available targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# ===== Infrastructure ===== +backend-setup: ## Set up S3 backend infrastructure (run this first) + cd infra && terraform init + cd infra && terraform apply -target=aws_s3_bucket.terraform_state -target=aws_dynamodb_table.terraform_state_lock -auto-approve + +setup-backend: ## Configure Terraform to use S3 backend (run after backend-setup) + cd infra && sed -i 's/# backend "s3" {/backend "s3" {/' versions.tf + cd infra && sed -i 's/# bucket/ bucket/' versions.tf + cd infra && sed -i 's/# key/ key/' versions.tf + cd infra && sed -i 's/# region/ region/' versions.tf + cd infra && sed -i 's/# dynamodb_table/ dynamodb_table/' versions.tf + cd infra && sed -i 's/# encrypt/ encrypt/' versions.tf + cd infra && sed -i 's/# }/}/' versions.tf + cd infra && terraform init -migrate-state + +init: ## Initialize Terraform + cd infra && terraform init + +plan: ## Show Terraform plan + cd infra && terraform plan + +apply: ## Apply Terraform changes + cd infra && terraform apply -auto-approve + +destroy: ## Destroy Terraform infrastructure + cd infra && terraform destroy -auto-approve + +output: ## Show Terraform outputs + cd infra && terraform output + +clean: ## Clean up Terraform state and lock files + cd infra && rm -f .terraform.lock.hcl + cd infra && rm -rf .terraform/ + +deploy-frontend: ## Deploy frontend files to S3 bucket + aws s3 sync public/ s3://calculator-127local-net --delete --exclude "*.DS_Store" --exclude "*.tmp" + aws cloudfront create-invalidation --distribution-id E35YHG58GE55V5 --paths "/*" + +deploy: backend-setup setup-backend plan apply output ## Full deployment pipeline with backend setup + +# ===== Local Development ===== +dev: ## Start local development server + PORT=8008 python dev_server.py + +dev-server: dev ## Alias for dev target + +# ===== Testing ===== +test: ## Run all tests (requires local dev server running) + pytest tests/ -v + +test-local: ## Run tests with local dev server (starts server, runs tests, stops server) + @echo "Starting local development server..." + @python dev_server.py & + @echo "Waiting for server to start..." + @sleep 3 + @echo "Running tests..." + @pytest tests/ -v + @echo "Stopping server..." + @pkill -f "python dev_server.py" || true + +test-watch: ## Run tests in watch mode (requires local dev server) + pytest tests/ -v -f --tb=short + +# ===== Dependencies ===== +venv: ## Create virtual environment + python3 -m venv venv + @echo "Virtual environment created. Activate with: source venv/bin/activate" + +install-deps: venv ## Install Python development dependencies in virtual environment + @echo "Installing dependencies in virtual environment..." + venv/bin/pip install -r requirements.txt + @echo "Dependencies installed. Activate virtual environment with: source venv/bin/activate" + +install-test-deps: install-deps ## Alias for install-deps + +activate: ## Show activation command + @echo "To activate virtual environment, run:" + @echo "source venv/bin/activate" + @echo "" + @echo "Then you can run:" + @echo "make test" + @echo "make dev" + +# ===== Code Quality ===== +lint: ## Run code linting and style checks + @echo "Checking Python files..." + @python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true + @echo "Checking JavaScript files..." + @echo "Note: Consider adding ESLint for JavaScript linting" + +format: ## Format Python code (requires black) + @echo "Formatting Python files..." + @python -m black . --check || echo "Black not installed. Install with: pip install black" + +format-fix: ## Format Python code and fix issues (requires black) + @echo "Formatting Python files..." + @python -m black . || echo "Black not installed. Install with: pip install black" + +# ===== Utility ===== +clean-all: clean ## Clean all generated files + @echo "Cleaning Python cache..." + @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + @find . -type f -name "*.pyc" -delete 2>/dev/null || true + @echo "Cleaning test cache..." + @rm -rf .pytest_cache/ || true + +status: ## Show current project status + @echo "=== Project Status ===" + @echo "Python version: $(shell python --version)" + @echo "Pip packages: $(shell pip list | grep -E "(pytest|selenium)" || echo "Test dependencies not installed")" + @echo "Local server: $(shell pgrep -f 'python dev_server.py' >/dev/null && echo 'Running' || echo 'Not running')" + @echo "Terraform: $(shell cd infra && terraform version 2>/dev/null | head -1 || echo 'Not initialized')" + +# ===== Quick Development Workflow ===== +dev-test: dev test ## Start dev server and run tests (in separate terminals) + @echo "Development server started. Run 'make test' in another terminal to run tests." + +quick-test: ## Quick test run (assumes server is running) + pytest tests/ -v -x --tb=short + +# ===== Documentation ===== +docs: ## Generate documentation (placeholder) + @echo "Documentation generation not yet implemented" + @echo "Consider adding Sphinx or similar for documentation" + +help-dev: ## Show development-specific help + @echo "=== Development Commands ===" + @echo "make dev - Start local development server" + @echo "make test - Run tests (requires server running)" + @echo "make test-local - Start server, run tests, stop server" + @echo "make install-deps - Install test dependencies" + @echo "make lint - Run code quality checks" + @echo "make format - Check code formatting" + @echo "make status - Show project status" diff --git a/dev_server.py b/dev_server.py new file mode 100755 index 0000000..9d5ad31 --- /dev/null +++ b/dev_server.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +import http.server, socketserver, os, pathlib + +ROOT = pathlib.Path(__file__).parent / 'public' +PORT = int(os.environ.get('PORT', '8008')) + +class SPAHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + path_only = self.path.split('?',1)[0].split('#',1)[0] + fs_path = self.translate_path(path_only) + if os.path.exists(fs_path) and os.path.isfile(fs_path): + return super().do_GET() + self.path = '/index.html' + return super().do_GET() + +if __name__ == '__main__': + os.chdir(ROOT) # serve from /public + with socketserver.TCPServer(('127.0.0.1', PORT), SPAHandler) as httpd: + print(f"Serving {ROOT} at http://127.0.0.1:{PORT}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + diff --git a/infra/backend.tf b/infra/backend.tf new file mode 100644 index 0000000..f68dc15 --- /dev/null +++ b/infra/backend.tf @@ -0,0 +1,45 @@ +# Backend infrastructure for Terraform state management +# This should be run first, before the main configuration + +resource "aws_s3_bucket" "terraform_state" { + bucket = "calculator-127local-net-terraform-state" + tags = var.tags +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_dynamodb_table" "terraform_state_lock" { + name = "terraform-state-lock" + billing_mode = "PAY_PER_REQUEST" + hash_key = "LockID" + + attribute { + name = "LockID" + type = "S" + } + + tags = var.tags +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..ceb88fc --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,171 @@ +locals { + origin_id = "s3-calculator-origin" + bucket_name = replace(var.primary_domain, ".", "-") +} + +resource "aws_s3_bucket" "site" { + bucket = local.bucket_name + tags = var.tags +} + +resource "aws_s3_bucket_ownership_controls" "site" { + bucket = aws_s3_bucket.site.id + rule { object_ownership = "BucketOwnerPreferred" } +} + +resource "aws_s3_bucket_public_access_block" "site" { + bucket = aws_s3_bucket.site.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "site" { + bucket = aws_s3_bucket.site.id + policy = data.aws_iam_policy_document.s3_policy.json +} + +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "s3_policy" { + statement { + sid = "AllowCloudFrontRead" + effect = "Allow" + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.site.arn}/*"] + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.site.arn] + } + } +} + +# CloudFront OAC (origin access control) +resource "aws_cloudfront_origin_access_control" "oac" { + name = "${local.bucket_name}-oac" + description = "OAC for ${local.bucket_name}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +# ACM certificate in us-east-1 for both domains +resource "aws_acm_certificate" "cert" { + provider = aws.us_east_1 + domain_name = var.primary_domain + subject_alternative_names = [var.secondary_domain] + validation_method = "DNS" + tags = var.tags +} + +resource "aws_route53_record" "cert_validation" { + for_each = { for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + type = dvo.resource_record_type + value = dvo.resource_record_value + } } + zone_id = data.aws_route53_zone.main.zone_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.value] +} + +resource "aws_acm_certificate_validation" "cert" { + provider = aws.us_east_1 + certificate_arn = aws_acm_certificate.cert.arn + validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn] +} + +# Get the hosted zone +data "aws_route53_zone" "main" { + name = var.hosted_zone +} + +# CloudFront distribution +resource "aws_cloudfront_distribution" "site" { + enabled = true + is_ipv6_enabled = true + comment = "Calculator site for ${var.primary_domain} and ${var.secondary_domain}" + default_root_object = "index.html" + aliases = [var.primary_domain, var.secondary_domain] + + origin { + domain_name = aws_s3_bucket.site.bucket_regional_domain_name + origin_id = local.origin_id + origin_access_control_id = aws_cloudfront_origin_access_control.oac.id + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = local.origin_id + viewer_protocol_policy = "redirect-to-https" + compress = true + min_ttl = 0 + default_ttl = 3600 + max_ttl = 86400 + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + acm_certificate_arn = aws_acm_certificate_validation.cert.certificate_arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } + + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + } + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + } + + tags = var.tags +} + +# Route53 alias for primary domain +resource "aws_route53_record" "primary_alias" { + zone_id = data.aws_route53_zone.main.zone_id + name = var.primary_domain + type = "A" + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } +} + +# Route53 alias for secondary domain +resource "aws_route53_record" "secondary_alias" { + zone_id = data.aws_route53_zone.main.zone_id + name = var.secondary_domain + type = "A" + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } +} + +output "bucket_name" { value = aws_s3_bucket.site.bucket } +output "distribution_id" { value = aws_cloudfront_distribution.site.id } +output "primary_domain" { value = var.primary_domain } +output "secondary_domain" { value = var.secondary_domain } +output "cloudfront_domain" { value = aws_cloudfront_distribution.site.domain_name } + diff --git a/infra/terraform.tfvars b/infra/terraform.tfvars new file mode 100644 index 0000000..ba91407 --- /dev/null +++ b/infra/terraform.tfvars @@ -0,0 +1,4 @@ +primary_domain = "calculator.127local.net" +secondary_domain = "calc.127local.net" +hosted_zone = "127local.net" +aws_region = "us-west-2" diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..3d057d6 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,24 @@ +variable "primary_domain" { + type = string + description = "Primary domain name (e.g., calculator.127local.net)" +} +variable "secondary_domain" { + type = string + description = "Secondary domain name (e.g., calc.127local.net)" +} +variable "hosted_zone" { + type = string + description = "Hosted zone name (e.g., 127local.net)" +} +variable "aws_region" { + type = string + default = "us-west-2" +} + +variable "tags" { + type = map(string) + default = { + Application = "calculator.127local" + Project = "calc-127local-net" + } +} diff --git a/infra/versions.tf b/infra/versions.tf new file mode 100644 index 0000000..e933e14 --- /dev/null +++ b/infra/versions.tf @@ -0,0 +1,28 @@ +terraform { + required_version = ">= 1.7.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.55" + } + } + + # S3 backend configuration - uncomment after backend infrastructure is created + backend "s3" { + bucket = "calculator-127local-net-terraform-state" + key = "terraform.tfstate" + region = "us-west-2" + dynamodb_table = "terraform-state-lock" + encrypt = true + } +} + +provider "aws" { + region = var.aws_region +} + +# ACM cert for CloudFront must be in us-east-1 +provider "aws" { + alias = "us_east_1" + region = "us-east-1" +} diff --git a/public/calculators/bandwidth.js b/public/calculators/bandwidth.js new file mode 100644 index 0000000..7e7433d --- /dev/null +++ b/public/calculators/bandwidth.js @@ -0,0 +1,21 @@ +import {fmt, revive, persist, labelInput, labelSelect} from '/js/util.js'; +export default { + id:'bandwidth', name:'Bandwidth / Bytes Converter', about:'Convert bits↔bytes and SI↔IEC units', + render(root){ + const key='calc_bandwidth_v1'; + const s = revive(key,{value:100, unit:'Mbps'}); + const units = ['bps','Kbps','Mbps','Gbps','Tbps','B/s','KB/s','MB/s','GB/s','TB/s','KiB/s','MiB/s','GiB/s','TiB/s']; + const factors = {'bps':1,'Kbps':1e3,'Mbps':1e6,'Gbps':1e9,'Tbps':1e12,'B/s':8,'KB/s':8e3,'MB/s':8e6,'GB/s':8e9,'TB/s':8e12,'KiB/s':8*1024,'MiB/s':8*1024**2,'GiB/s':8*1024**3,'TiB/s':8*1024**4}; + const ui = document.createElement('div'); + ui.append(labelInput('Value','number','value', s.value,{step:'0.000001',min:'0'}), labelSelect('Unit','unit', s.unit, units.map(u=>[u,u]))); + const out = document.createElement('div'); out.className='result'; ui.append(out); + function calc(){ + const v = +ui.querySelector('[name=value]').value||0; + const unit = ui.querySelector('[name=unit]').value; + const bps = v * factors[unit]; + out.innerHTML = units.map(u=>`
${u}: ${fmt.format(bps / factors[u])}
`).join(''); + persist(key,{value:v, unit}); + } + ui.addEventListener('input', calc); calc(); root.append(ui); + } +} diff --git a/public/calculators/currency.js b/public/calculators/currency.js new file mode 100644 index 0000000..6edcfd0 --- /dev/null +++ b/public/calculators/currency.js @@ -0,0 +1,399 @@ +import {revive, persist, labelInput, labelSelect} from '/js/util.js'; + +export default { + id:'currency', name:'Currency Converter', about:'Convert between currencies using real-time exchange rates.', + render(root){ + const key='calc_currency_v1'; + const s = revive(key,{amount:100, from:'USD', to:'EUR', manualRate: null}); + const ui = document.createElement('div'); + + // Popular currencies with better formatting + const currencies = [ + ['USD', 'US Dollar'], + ['EUR', 'Euro'], + ['GBP', 'British Pound'], + ['JPY', 'Japanese Yen'], + ['CAD', 'Canadian Dollar'], + ['AUD', 'Australian Dollar'], + ['CHF', 'Swiss Franc'], + ['CNY', 'Chinese Yuan'], + ['INR', 'Indian Rupee'], + ['BRL', 'Brazilian Real'] + ]; + + // Create a more user-friendly form layout + const formContainer = document.createElement('div'); + formContainer.style.cssText = ` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; + margin-bottom: 20px; + `; + + // Amount input (full width) + const amountDiv = document.createElement('div'); + amountDiv.style.gridColumn = '1 / -1'; + amountDiv.innerHTML = ` + + + `; + + // From currency (left column) - using Select Lite + const fromDiv = document.createElement('div'); + fromDiv.innerHTML = ` + + + `; + + // To currency (right column) - using Select Lite + const toDiv = document.createElement('div'); + toDiv.innerHTML = ` + + + `; + + formContainer.append(amountDiv, fromDiv, toDiv); + ui.append(formContainer); + + // Manual rate input option + const manualRateDiv = document.createElement('div'); + manualRateDiv.innerHTML = ` +
+ + +
+ Format: 1 ${s.from} = X ${s.to}
+ Example: If 1 USD = 0.85 EUR, enter 0.85
+ Leave empty to use current market rates +
+
+ `; + ui.append(manualRateDiv); + + // Fetch rates button with better styling + const fetchBtn = document.createElement('button'); + fetchBtn.type = 'button'; + fetchBtn.textContent = 'Update Exchange Rates'; + fetchBtn.className = 'btn'; + fetchBtn.style.cssText = ` + width: 100%; + padding: 12px; + margin: 15px 0; + font-size: 16px; + font-weight: 500; + background: var(--accent); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; + `; + fetchBtn.addEventListener('mouseenter', () => fetchBtn.style.background = 'var(--accent-hover)'); + fetchBtn.addEventListener('mouseleave', () => fetchBtn.style.background = 'var(--accent)'); + ui.append(fetchBtn); + + const out = document.createElement('div'); + out.className = 'result'; + out.style.cssText = ` + margin: 20px 0; + padding: 15px; + background: var(--k-bg); + border-radius: 8px; + border-left: 4px solid var(--accent); + `; + ui.append(out); + + const status = document.createElement('div'); + status.className='status'; + ui.append(status); + + // Static rates from today (2025-09-01) - no prefetching + const staticRates = { + USD: 1, + EUR: 0.856, + GBP: 0.741, + JPY: 147.15, + CAD: 1.37, + AUD: 1.53, + CHF: 0.801, + CNY: 7.13, + INR: 88.19, + BRL: 5.43 + }; + + let rates = staticRates; + let lastFetch = 0; + const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + + // Function to get current static rate for currency pair + function getCurrentStaticRate(from, to) { + if (from === to) return 1; + if (rates && rates[from] && rates[to]) { + return rates[to] / rates[from]; + } + return null; + } + + // Function to update custom rate with current static rate + function updateCustomRateWithStatic() { + const from = ui.querySelector('[name=from]').value; + const to = ui.querySelector('[name=to]').value; + const staticRate = getCurrentStaticRate(from, to); + + if (staticRate !== null) { + const manualRateInput = ui.querySelector('[name=manualRate]'); + // Update manual rate to current market rate when fetching new rates + manualRateInput.value = staticRate.toFixed(4); + manualRateInput.placeholder = `Current rate: ${staticRate.toFixed(4)}`; + // Trigger calculation with the new rates + calc(); + } + } + + async function fetchRates(){ + const now = Date.now(); + if (rates && (now - lastFetch) < CACHE_DURATION) { + status.textContent = 'Using cached rates (updated within 5 minutes)'; + status.className = 'status success'; + status.style.display = 'block'; + setTimeout(() => { + status.textContent = ''; + status.style.display = 'none'; + }, 3000); + return rates; + } + + try { + status.textContent = 'Fetching latest exchange rates...'; + status.className = 'status loading'; + + const response = await fetch('https://api.exchangerate-api.com/v4/latest/USD'); + if (!response.ok) throw new Error('Failed to fetch rates'); + + const data = await response.json(); + rates = data.rates; + rates.USD = 1; // Base currency + lastFetch = now; + + status.textContent = 'Rates updated successfully!'; + status.className = 'status success'; + status.style.display = 'block'; + setTimeout(() => { + status.textContent = ''; + status.style.display = 'none'; + }, 1500); + + // Update custom rate with new rates and recalculate + updateCustomRateWithStatic(); + + return rates; + } catch (error) { + console.error('Failed to fetch exchange rates:', error); + status.textContent = 'Failed to fetch rates. Using static rates from today.'; + status.className = 'status error'; + // Keep using static rates + return rates; + } + } + + function calc(){ + const amount = +ui.querySelector('[name=amount]').value || 0; + const from = ui.querySelector('[name=from]').value; + const to = ui.querySelector('[name=to]').value; + const manualRate = +ui.querySelector('[name=manualRate]').value || null; + + console.log('Calc function called with:', { amount, from, to, manualRate }); + + if (amount <= 0) { + out.innerHTML = '
Enter a positive amount to convert
'; + return; + } + + if (from === to) { + out.innerHTML = ` +
+ ${amount.toFixed(2)} ${from} +
+
+ Same currency - no conversion needed +
+ `; + return; + } + + let conversionRate; + let rateSource; + let rateDate; + + if (manualRate && manualRate > 0) { + console.log('Using manual rate:', manualRate); + // Use manual rate + conversionRate = manualRate; + + // Determine if this is user-entered or default custom rate + const staticRate = staticRates[to] / staticRates[from]; + const isUserEntered = Math.abs(manualRate - staticRate) > 0.0001; // Allow for floating point precision + + rateSource = isUserEntered ? 'Custom rate' : 'Static rates'; + rateDate = isUserEntered ? 'Manual input' : '2025-09-01'; + + out.innerHTML = ` +
+ ${(amount * conversionRate).toFixed(2)} ${to} +
+
+ Conversion: ${amount} ${from} × ${conversionRate.toFixed(4)} = ${(amount * conversionRate).toFixed(2)} ${to} +
+
+ Rate: 1 ${from} = ${conversionRate.toFixed(4)} ${to}
+ Source: ${rateSource}
+ Date: ${rateDate} +
+ `; + } else if (rates) { + console.log('Using market rates, manual rate was:', manualRate); + // Use fetched or static rates (fallback when no manual rate) + conversionRate = rates[to] / rates[from]; + rateSource = lastFetch > 0 ? 'Live rates' : 'Static rates'; + rateDate = lastFetch > 0 ? new Date(lastFetch).toLocaleString() : '2025-09-01'; + + out.innerHTML = ` +
+ ${(amount * conversionRate).toFixed(2)} ${to} +
+
+ Conversion: ${amount} ${from} × ${conversionRate.toFixed(4)} = ${(amount * conversionRate).toFixed(2)} ${to} +
+
+ Rate: 1 ${from} = ${conversionRate.toFixed(4)} ${to}
+ Rate: 1 ${to} = ${(1/conversionRate).toFixed(4)} ${from}
+ Source: ${rateSource}
+ Rates accurate as of: ${rateDate} +
+ `; + } else { + out.innerHTML = '
Unable to calculate conversion
'; + return; + } + + persist(key, {amount, from, to, manualRate}); + } + + // Manual rate input handler + const manualRateInput = manualRateDiv.querySelector('[name="manualRate"]'); + manualRateInput.value = s.manualRate || ''; + + // Debug logging + console.log('Setting up manual rate input handler'); + + // Set up the event listener + manualRateInput.addEventListener('input', (e) => { + console.log('Manual rate input event fired:', e.target.value); + + // Clear any existing status messages + status.textContent = ''; + status.style.display = 'none'; + + // Update the stored manual rate + s.manualRate = +e.target.value || null; + console.log('Updated manual rate to:', s.manualRate); + + // Simple test - just update the display immediately + if (s.manualRate && s.manualRate > 0) { + const amount = +ui.querySelector('[name=amount]').value || 0; + const from = ui.querySelector('[name=from]').value; + const to = ui.querySelector('[name=to]').value; + + console.log('Immediate calculation test:', { amount, from, to, manualRate: s.manualRate }); + + // Determine if this is user-entered or default custom rate + const staticRate = staticRates[to] / staticRates[from]; + const isUserEntered = Math.abs(s.manualRate - staticRate) > 0.0001; // Allow for floating point precision + + const rateSource = isUserEntered ? 'Custom rate' : 'Static rates'; + + // Show immediate result + out.innerHTML = ` +
+ ${(amount * s.manualRate).toFixed(2)} ${to} +
+
+ Conversion: ${amount} ${from} × ${s.manualRate.toFixed(4)} = ${(amount * s.manualRate).toFixed(2)} ${to} +
+
+ Rate: 1 ${from} = ${s.manualRate.toFixed(4)} ${to}
+ Source: ${rateSource} +
+ `; + } else { + // Fall back to normal calculation + calc(); + } + }); + + // Also add change event as backup + manualRateInput.addEventListener('change', (e) => { + console.log('Manual rate change event fired:', e.target.value); + s.manualRate = +e.target.value || null; + calc(); + }); + + // Currency change handlers + const fromSelect = fromDiv.querySelector('[name=from]'); + const toSelect = toDiv.querySelector('[name=to]'); + + fromSelect.addEventListener('change', () => { + s.from = fromSelect.value; + // Update manual rate description and custom rate + const desc = manualRateDiv.querySelector('div'); + desc.innerHTML = desc.innerHTML.replace(/1 [A-Z]{3} = X [A-Z]{3}/, `1 ${s.from} = X ${s.to}`); + updateCustomRateWithStatic(); + calc(); + }); + + toSelect.addEventListener('change', () => { + s.to = toSelect.value; + // Update manual rate description and custom rate + const desc = manualRateDiv.querySelector('div'); + desc.innerHTML = desc.innerHTML.replace(/1 [A-Z]{3} = X [A-Z]{3}/, `1 ${s.from} = X ${s.to}`); + updateCustomRateWithStatic(); + calc(); + }); + + // Amount input handler + const amountInput = amountDiv.querySelector('[name="amount"]'); + amountInput.addEventListener('input', calc); + + // Fetch rates button click handler + fetchBtn.addEventListener('click', async () => { + await fetchRates(); + calc(); // Recalculate with new rates + }); + + // Initialize custom rate with current static rate + updateCustomRateWithStatic(); + + // Initial calculation + calc(); + root.append(ui); + } +} diff --git a/public/calculators/interest.js b/public/calculators/interest.js new file mode 100644 index 0000000..7580cab --- /dev/null +++ b/public/calculators/interest.js @@ -0,0 +1,50 @@ +import {currency, revive, persist, labelInput, labelSelect} from '/js/util.js'; +export default { + id:'interest', name:'Interest (Simple & Compound)', about:'Compute simple or compound interest with flexible compounding and contributions.', + render(root){ + const key='calc_interest_v1'; + const s = revive(key,{principal:1000, rate:5, years:3, compound:'12', contrib:0, contribFreq:'12'}); + const ui = document.createElement('div'); + ui.append( + labelInput('Principal','number','principal', s.principal,{step:'0.01',min:'0'}), + labelInput('Annual rate (%)','number','rate', s.rate,{step:'0.0001',min:'0'}), + labelSelect('Compounding','compound', s.compound, [['1','Yearly'],['4','Quarterly'],['12','Monthly'],['365','Daily'],['0','Simple (no compounding)']]), + labelInput('Years','number','years', s.years,{step:'0.1',min:'0'}), + labelInput('Recurring contribution (per period below)','number','contrib', s.contrib,{step:'0.01',min:'0'}), + labelSelect('Contribution frequency','contribFreq', s.contribFreq, [['1','Yearly'],['4','Quarterly'],['12','Monthly']]) + ); + const out = document.createElement('div'); out.className='result'; ui.append(out); + + function calc(){ + const P = +ui.querySelector('[name=principal]').value||0; + const r = (+ui.querySelector('[name=rate]').value||0)/100; + const years = +ui.querySelector('[name=years]').value||0; + const n = +ui.querySelector('[name=compound]').value; // 0 => simple + const A = +ui.querySelector('[name=contrib]').value||0; + const f = +ui.querySelector('[name=contribFreq]').value||1; + + let future=0, interest=0; + if(n===0){ + interest = P * r * years; + const contribs = A * f * years; + future = P + interest + contribs; + }else{ + const periods = n * years; + const i = r / n; + future = P * Math.pow(1+i, periods); + if(A>0){ + const eff = Math.pow(1+i, n/f) - 1; + const m = Math.round(periods * f / n); + future += A * ((Math.pow(1+eff, m) - 1) / eff); + } + interest = future - P - (A>0?A*Math.round(n*years * f / n):0); + } + out.innerHTML = ` +
Future value: ${currency(future)}
+
Estimated interest earned: ${currency(Math.max(0,interest))}
+ `; + persist(key,{principal:P, rate:r*100, years, compound:String(n), contrib:A, contribFreq:String(f)}); + } + ui.addEventListener('input', calc); calc(); root.append(ui); + } +} diff --git a/public/calculators/nmea.js b/public/calculators/nmea.js new file mode 100644 index 0000000..4ff84d0 --- /dev/null +++ b/public/calculators/nmea.js @@ -0,0 +1,22 @@ +import {revive, persist, labelInput} from '/js/util.js'; +export default { + id:'nmea', name:'NMEA 0183 Checksum', about:'Paste without the leading $ and without *XX.', + render(root){ + const key='calc_nmea_v1'; + const s = revive(key,{sentence:'GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,', expected:''}); + const ui = document.createElement('div'); + ui.append(labelInput('Sentence (no $ and no *XX)','text','sentence', s.sentence), labelInput('Expected checksum (optional, hex)','text','expected', s.expected,{placeholder:'e.g. 47'})); + const out = document.createElement('div'); out.className='result'; ui.append(out); + function checksum(str){ let x=0; for(const ch of str) x ^= ch.charCodeAt(0); return x; } + function calc(){ + const sentence = ui.querySelector('[name=sentence]').value.trim(); + const exp = ui.querySelector('[name=expected]').value.trim(); + const sum = checksum(sentence); + const hex = sum.toString(16).toUpperCase().padStart(2,'0'); + let html = `
Computed: *${hex}
`; + if(exp){ const ok = hex === exp.toUpperCase(); html += `
${ok ? 'Match' : 'Mismatch'} (expected *${exp.toUpperCase()})
`; } + out.innerHTML = html; persist(key,{sentence, expected:exp}); + } + ui.addEventListener('input', calc); calc(); root.append(ui); + } +} diff --git a/public/calculators/raid.js b/public/calculators/raid.js new file mode 100644 index 0000000..269370a --- /dev/null +++ b/public/calculators/raid.js @@ -0,0 +1,32 @@ +import {fmt, revive, persist, labelInput, labelSelect} from '/js/util.js'; +export default { + id:'raid', name:'RAID Usable Capacity', about:'Estimates only; assumes identical drives. Controller/FS overhead not included.', + render(root){ + const key='calc_raid_v1'; + const s = revive(key,{level:'5', drives:6, size:18}); + const ui = document.createElement('div'); + ui.append( + labelSelect('RAID level','level', s.level, [['0','RAID 0 (stripe)'],['1','RAID 1 (mirror)'],['5','RAID 5 (1 disk parity)'],['6','RAID 6 (2 disk parity)'],['10','RAID 10 (mirror+stripe)']]), + labelInput('Number of drives','number','drives', s.drives,{min:'1',step:'1'}), + labelInput('Drive size (TB, decimal)','number','size', s.size,{min:'0',step:'0.01'}) + ); + const out = document.createElement('div'); out.className='result'; ui.append(out); + + function calc(){ + const L = ui.querySelector('[name=level]').value; + const N = Math.max(1, parseInt(ui.querySelector('[name=drives]').value||0,10)); + const S = (+ui.querySelector('[name=size]').value||0) * 1e12; + let usable=0, ft=''; + if(L==='0'){ usable = N*S; ft='0 disk'; } + else if(L==='1'){ usable = Math.floor(N/2)*S; ft='can lose all but 1 in each mirror pair'; } + else if(L==='5'){ usable = (N-1)*S; ft='1 disk'; } + else if(L==='6'){ usable = (N-2)*S; ft='2 disks'; } + else if(L==='10'){ usable = Math.floor(N/2)*S; ft='1 disk per mirror pair'; } + const teb = usable / (1024**4); + const tb = usable / 1e12; + out.innerHTML = `
Usable: ${fmt.format(tb)} TB · ${fmt.format(teb)} TiB
Fault tolerance: ${ft}
`; + persist(key,{level:L, drives:N, size:+ui.querySelector('[name=size]').value}); + } + ui.addEventListener('input', calc); calc(); root.append(ui); + } +} diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..a11f1c9 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,207 @@ +/* ---- Color System ---- */ +:root{ + /* light defaults */ + --bg:#f6f7fb; --card:#ffffff; --text:#0f1220; --muted:#5b6473; + --border:#dfe3ee; --accent:#2563eb; --accent2:#7c3aed; + --k-bg:#f1f5ff; --k-border:#cdd9ff; + --br:16px; --gap:14px; --shadow:0 6px 20px rgba(0,0,0,.08); --max:1100px; +} +@media (prefers-color-scheme: dark){ + :root{ + --bg:#0f1220; --card:#151933; --text:#e7ebf3; --muted:#9aa3b2; + --border:#242a44; --accent:#7dd3fc; --accent2:#a78bfa; + --k-bg:rgba(125,211,252,.06); --k-border:#2a3357; + --shadow:0 8px 30px rgba(0,0,0,.25); + } +} +/* explicit overrides win over system */ +:root[data-theme="light"]{ + --bg:#f6f7fb; --card:#ffffff; --text:#0f1220; --muted:#5b6473; + --border:#dfe3ee; --accent:#2563eb; --accent2:#7c3aed; + --k-bg:#f1f5ff; --k-border:#cdd9ff; --shadow:0 6px 20px rgba(0,0,0,.08); +} +:root[data-theme="dark"]{ + --bg:#0f1220; --card:#151933; --text:#e7ebf3; --muted:#9aa3b2; + --border:#242a44; --accent:#7dd3fc; --accent2:#a78bfa; + --k-bg:rgba(125,211,252,.06); --k-border:#2a3357; + --shadow:0 8px 30px rgba(0,0,0,.25); +} + +:root { color-scheme: light; } +@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } } +:root[data-theme="light"] { color-scheme: light; } +:root[data-theme="dark"] { color-scheme: dark; } + +input, select, textarea { color-scheme: inherit; } + +:root{ + --select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E"); +} + +select{ + appearance: none; + -webkit-appearance: none; + background-color: var(--card); + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 2.25rem 10px 10px; /* room for arrow */ + background-image: var(--select-arrow); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 12px auto; +} + +select:focus{ + outline: none; + border-color: var(--accent2); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent); +} + +select:disabled{ opacity:.55; cursor:not-allowed; } + +/* ---- Base ---- */ +*{box-sizing:border-box} +html,body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,sans-serif} +.wrap{max-width:var(--max);margin:0 auto;padding:16px} + +.bar{position:sticky;top:0;background:linear-gradient(180deg,rgba(0,0,0,.06),rgba(0,0,0,0));backdrop-filter:blur(8px);border-bottom:1px solid var(--border);z-index:10} +.bar__inner{display:flex;align-items:center;gap:12px;justify-content:space-between} +.brand{font-weight:700} + +.btn{background:transparent;border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:999px;cursor:pointer} + +/* ---- Layout ---- */ +.layout{display:grid;grid-template-columns:240px 1fr;gap:16px} +@media (max-width: 820px){ + .layout{grid-template-columns:1fr} +} + +/* ---- Vertical nav ---- */ +.sidenav{border:1px solid var(--border);border-radius:var(--br);padding:10px;background:var(--card);box-shadow:var(--shadow);position:sticky;top:70px;height:fit-content} +.sidenav .navlist{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none} +.sidenav .navlist button{width:100%;text-align:left;background:transparent;border:1px solid var(--border);color:var(--text);padding:10px;border-radius:10px;cursor:pointer} +.sidenav .navlist button[aria-current="page"]{border-color:var(--accent2);box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent) inset} + +.sidenav .navlist a{ + display:block; text-decoration:none; + background:transparent; border:1px solid var(--border); + color:var(--text); padding:10px; border-radius:10px; +} +.sidenav .navlist a[aria-current="page"]{ + border-color:var(--accent2); + box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent) inset; +} + +/* Select Lite (themed, accessible, tiny) */ +.select-lite{ position:relative; } +.select-lite > select{ + position:absolute; inset:auto auto auto auto; /* keep in DOM but invisible */ + opacity:0; pointer-events:none; width:0; height:0; +} +.select-lite__button{ + width:100%; + text-align:left; + background:var(--card); + color:var(--text); + border:1px solid var(--border); + border-radius:10px; + padding:10px 2.25rem 10px 10px; + cursor:pointer; + background-image: var(--select-arrow, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E")); + background-repeat:no-repeat; background-position:right 10px center; background-size:12px auto; +} +.select-lite__button:focus{ + outline:none; + border-color:var(--accent2); + box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent); +} +.select-lite__menu{ + position:absolute; z-index:1000; + top:calc(100% + 6px); left:0; right:0; + background:var(--card); + color:var(--text); + border:1px solid var(--border); + border-radius:10px; + box-shadow:var(--shadow); + max-height:260px; overflow:auto; padding:6px; +} +.select-lite__option{ + padding:8px 10px; border-radius:8px; outline:none; +} +.select-lite__option[aria-selected="true"], +.select-lite__option:hover, +.select-lite__option:focus{ background:var(--accent); color:white; } + +.select-lite__option.search-match { + background: var(--accent) !important; + color: white !important; + font-weight: bold !important; +} + + +/* ---- Main content ---- */ +.content{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--gap)} +.card{background:var(--card);padding:14px;border-radius:var(--br);box-shadow:var(--shadow);border:1px solid var(--border)} +.card h2{margin:0 0 6px 0;font-size:18px} +.muted{color:var(--muted);font-size:14px} +label{display:block;margin-top:10px;margin-bottom:4px;color:var(--muted);font-size:14px} +input,select,textarea{width:100%;background:transparent;color:var(--text);border:1px solid var(--border);border-radius:10px;padding:10px} +.result{margin-top:12px;padding:10px;border:1px dashed var(--k-border);border-radius:10px;background:var(--k-bg)} +.k{padding:2px 6px;border-radius:6px;border:1px solid var(--k-border);background:var(--k-bg)} +.foot{color:var(--muted);font-size:13px;margin-top:20px} + +/* ---- Status indicators ---- */ +.status { + padding: 8px 12px; + border-radius: 8px; + font-size: 14px; + margin-top: 8px; + text-align: center; +} + +.status.loading { + background-color: var(--k-bg); + color: var(--accent); + border: 1px solid var(--k-border); +} + +.status.success { + background-color: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} + +.status.error { + background-color: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} + +/* Dark theme status adjustments */ +@media (prefers-color-scheme: dark) { + .status.success { + background-color: #064e3b; + color: #6ee7b7; + border: 1px solid #065f46; + } + + .status.error { + background-color: #450a0a; + color: #fca5a5; + border: 1px solid #7f1d1d; + } +} + +:root[data-theme="dark"] .status.success { + background-color: #064e3b; + color: #6ee7b7; + border: 1px solid #065f46; +} + +:root[data-theme="dark"] .status.error { + background-color: #450a0a; + color: #fca5a5; + border: 1px solid #7f1d1d; +} + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e16d5a1 --- /dev/null +++ b/public/index.html @@ -0,0 +1,30 @@ + + + + + + calculator.127local.net + + + + + + + +
+
+
calculator.127local.net
+ +
+
+ +
+ +
+
+ + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..ab5182a --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,163 @@ +import {el, initTheme, enhanceSelects} from './util.js'; + +const CALCS = [ + { id:'interest', name:'Interest (Simple & Compound)', about:'Simple/compound interest', path:'../calculators/interest.js' }, + { id:'raid', name:'RAID', about:'Usable capacity', path:'../calculators/raid.js' }, + { id:'bandwidth', name:'Bandwidth', about:'Bits↔bytes unit conv.', path:'../calculators/bandwidth.js' }, + { id:'nmea', name:'NMEA', about:'0183 XOR checksum', path:'../calculators/nmea.js' }, + { id:'currency', name:'Currency Converter', about:'Convert between currencies', path:'../calculators/currency.js' } +]; + +const navEl = document.getElementById('nav'); +const viewEl = document.getElementById('view'); +const themeBtn= document.getElementById('themeToggle'); +initTheme(themeBtn); + +const moduleCache = new Map(); +const viewCache = new Map(); + +function metaById(id){ return CALCS.find(c=>c.id===id) || CALCS[0] } + +/* ---------- History API routing (path + query) ---------- */ +function normalize(id, params){ + const qs = params && [...params].length ? `?${params.toString()}` : ''; + return `/${id}${qs}`; +} +function parseRoute(){ + const path = location.pathname.replace(/^\/+/, ''); + const id = path || 'interest'; + const params = new URLSearchParams(location.search); + return { id, params }; +} +function setRoute(id, params, {replace=true}={}){ + const target = normalize(id, params || new URLSearchParams()); + const current = `${location.pathname}${location.search}`; + if (target === current) return; // no-op if unchanged + if (replace) history.replaceState(null, '', target); + else history.pushState(null, '', target); + mount(); // re-render +} + +function formToParams(card){ + const p = new URLSearchParams(); + card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{ + let v = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value; + if (v !== '') p.set(el.name, v); + }); + return p; +} +function paramsToForm(card, params){ + let touched = false; + card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{ + const k = el.name; + if (!params.has(k)) return; + const v = params.get(k); + if (el.type === 'checkbox'){ + const val = (v==='1' || v==='true'); + if(el.checked !== val){ el.checked = val; touched = true; } + }else{ + if(el.value !== v){ el.value = v; touched = true; } + } + }); + if (touched) card.dispatchEvent(new Event('input', {bubbles:true})); +} + +/* ---------- Nav: anchors + minimal history ---------- */ +function renderNav(active){ + navEl.innerHTML = ''; + const ul = el('ul',{class:'navlist'}); + for(const c of CALCS){ + const a = el('a', { + href: `/${c.id}`, + 'data-calc': c.id, + 'aria-current': c.id===active ? 'page' : null + }, c.name); + ul.append(el('li',{}, a)); + } + navEl.append(ul); +} + +// delegate clicks so we can decide push vs replace +navEl.addEventListener('click', (e)=>{ + const a = e.target.closest('a[data-calc]'); + if(!a) return; + // allow new tab/window/defaults + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey) return; + e.preventDefault(); + const id = a.dataset.calc; + // shift-click = commit to history (push); otherwise replace (minimal history) + setRoute(id, null, {replace: !e.shiftKey}); +}); + +function loadModule(spec){ + if(!moduleCache.has(spec)) moduleCache.set(spec, import(spec)); + return moduleCache.get(spec); +} + +async function ensureMounted(id){ + if(viewCache.has(id)) return viewCache.get(id); + const meta = metaById(id); + const card = el('section',{class:'card'}); + viewCache.set(id, card); + + // placeholder heading + card.append(el('h2',{}, meta.name), el('div',{class:'muted'}, meta.about||'')); + try{ + const mod = await loadModule(meta.path); + const calc = mod.default; + card.innerHTML = ''; + card.append(el('h2',{}, calc.name || meta.name)); + if(calc.about) card.append(el('div',{class:'muted'}, calc.about)); + calc.render(card); + enhanceSelects(card); + }catch(e){ + console.error(e); + card.innerHTML = `
Failed to load calculator. Check console.
`; + } + return card; +} + +function attachUrlSync(card, id){ + // Debounced replaceState on input changes (never pushes to history) + let t; + card.addEventListener('input', ()=>{ + clearTimeout(t); + t = setTimeout(()=>{ + const newParams = formToParams(card); + const {id:curId, params:curParams} = parseRoute(); + const sameId = (curId === id); + const sameQs = sameId && newParams.toString() === curParams.toString(); + if(!sameQs) setRoute(id, newParams, {replace:true}); + }, 150); + }); +} + +async function show(id, params){ + renderNav(id); + for(const [cid, card] of viewCache.entries()){ + card.style.display = (cid===id) ? '' : 'none'; + } + if(!viewCache.has(id)){ + const card = await ensureMounted(id); + viewEl.append(card); + attachUrlSync(card, id); + } + paramsToForm(viewCache.get(id), params); +} + +async function mount(){ + const {id, params} = parseRoute(); + // normalize root to /interest once + if(!location.pathname || location.pathname === '/' ){ + setRoute('interest', params, {replace:true}); + return; + } + await show(id, params); + + // idle prefetch + const idle = window.requestIdleCallback || ((fn)=>setTimeout(fn,300)); + idle(()=> CALCS.forEach(c=> loadModule(c.path).catch(()=>{}))); +} + +addEventListener('popstate', mount); // back/forward +mount(); diff --git a/public/js/util.js b/public/js/util.js new file mode 100644 index 0000000..34c7021 --- /dev/null +++ b/public/js/util.js @@ -0,0 +1,206 @@ +// public/js/util.js +export const fmt = new Intl.NumberFormat(undefined,{maximumFractionDigits:6}); +export const currency = (v,cur='USD')=> new Intl.NumberFormat(undefined,{style:'currency',currency:cur,maximumFractionDigits:2}).format(v); +export const persist = (key, obj) => localStorage.setItem(key, JSON.stringify(obj)); +export const revive = (key, fallback={}) => { try { return JSON.parse(localStorage.getItem(key)) || fallback } catch { return fallback } }; + +export function el(tag, attrs={}, children=[]){ + const x = document.createElement(tag); + for (const [k,v] of Object.entries(attrs||{})){ + if (k === 'class') x.className = v ?? ''; + else if (k === 'html') x.innerHTML = v ?? ''; + else if (k === 'on' && v && typeof v === 'object'){ + for (const [evt, fn] of Object.entries(v)){ if (typeof fn === 'function') x.addEventListener(evt, fn, false); } + } else { + if (v !== null && v !== undefined && v !== false){ + x.setAttribute(k, v === true ? '' : String(v)); + } + } + } + (Array.isArray(children) ? children : [children]).forEach(ch => { if (ch != null) x.append(ch) }); + return x; +} + +export function labelInput(labelText, type, name, value, attrs={}){ + const w = el('div'); + w.append(el('label',{html:labelText})); + const input = el('input',{type, name, value: String(value ?? ''), ...attrs}); + w.append(input); return w; +} +export function labelSelect(labelText, name, value, options){ + const w = el('div'); + w.append(el('label',{html:labelText})); + const sel = el('select',{name, 'data-ui':'lite'}); // opt into Select Lite + options.forEach(([val, text])=>{ + const opt = el('option',{value:val}); opt.textContent = text; + if (String(val) === String(value)) opt.selected = true; + sel.append(opt); + }); + w.append(sel); return w; +} + +/* ---- Theme helpers ---- */ +export function applyTheme(mode){ + if(mode === 'auto'){ document.documentElement.removeAttribute('data-theme'); } + else{ document.documentElement.setAttribute('data-theme', mode); } + localStorage.setItem('theme', mode); +} +export function initTheme(toggleBtn){ + const saved = localStorage.getItem('theme') || 'auto'; + applyTheme(saved); + if(toggleBtn){ + toggleBtn.textContent = titleForTheme(saved); + toggleBtn.addEventListener('click', ()=>{ + const next = nextTheme(localStorage.getItem('theme')||'auto'); + applyTheme(next); + toggleBtn.textContent = titleForTheme(next); + }); + } +} +function nextTheme(mode){ return mode==='auto' ? 'light' : mode==='light' ? 'dark' : 'auto' } +function titleForTheme(mode){ return mode==='auto' ? 'Auto' : mode==='light' ? 'Light' : 'Dark' } + +/* ---- Select Lite: progressively enhance