diff --git a/src/islands/calculators/BmiCalculator.tsx b/src/islands/calculators/BmiCalculator.tsx new file mode 100644 index 0000000..409d9e8 --- /dev/null +++ b/src/islands/calculators/BmiCalculator.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { computeBmi, bmiCategory, healthyWeightRange, lbToKg, ftInToCm } from '@/tools/calculators/bmi.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { + intro: 'Work out your Body Mass Index from your height and weight. Everything is calculated in your browser — nothing is sent anywhere.', + metric: 'Metric', imperial: 'Imperial', height: 'Height', weight: 'Weight', + cm: 'cm', kg: 'kg', ft: 'ft', in: 'in', lb: 'lb', + result: 'Your BMI', healthy: 'Healthy weight for your height', + underweight: 'Underweight', normal: 'Normal', overweight: 'Overweight', obese: 'Obese', + }, + id: { + intro: 'Hitung Indeks Massa Tubuh (BMI) dari tinggi dan berat badan Anda. Semua dihitung di browser Anda — tidak ada yang dikirim ke mana pun.', + metric: 'Metrik', imperial: 'Imperial', height: 'Tinggi', weight: 'Berat', + cm: 'cm', kg: 'kg', ft: 'ft', in: 'in', lb: 'lb', + result: 'BMI Anda', healthy: 'Berat sehat untuk tinggi Anda', + underweight: 'Kurus', normal: 'Normal', overweight: 'Berlebih', obese: 'Obesitas', + }, +}; + +const CAT_COLOR: Record = { + underweight: 'bg-sky-200 dark:bg-sky-900/40', + normal: 'bg-lime-200 dark:bg-lime-900/40', + overweight: 'bg-amber-200 dark:bg-amber-900/40', + obese: 'bg-rose-200 dark:bg-rose-900/40', +}; + +export default function BmiCalculator({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [unit, setUnit] = useState<'metric' | 'imperial'>('metric'); + const [cm, setCm] = useState('175'); + const [kg, setKg] = useState('70'); + const [ft, setFt] = useState('5'); + const [inch, setInch] = useState('9'); + const [lb, setLb] = useState('154'); + + const heightCm = unit === 'metric' ? Number(cm) || 0 : ftInToCm(Number(ft) || 0, Number(inch) || 0); + const weightKg = unit === 'metric' ? Number(kg) || 0 : lbToKg(Number(lb) || 0); + const bmi = computeBmi(weightKg, heightCm); + const cat = bmiCategory(bmi); + const range = healthyWeightRange(heightCm); + const input = 'w-full border-2 border-border bg-muted p-2 text-sm tabular-nums'; + + const kgToUnit = (v: number) => unit === 'metric' ? `${v.toFixed(1)} ${t.kg}` : `${(v / 0.45359237).toFixed(0)} ${t.lb}`; + + return ( +
+

{t.intro}

+ +
+ {(['metric', 'imperial'] as const).map(u => ( + + ))} +
+ + {unit === 'metric' ? ( +
+ + +
+ ) : ( +
+ + + +
+ )} + + {bmi > 0 && ( +
+
{t.result}
+
{bmi.toFixed(1)}
+
{t[cat]}
+
{t.healthy}: {kgToUnit(range.min)} – {kgToUnit(range.max)}
+
+ )} +
+ ); +} diff --git a/src/islands/calculators/DateDuration.tsx b/src/islands/calculators/DateDuration.tsx new file mode 100644 index 0000000..9a8616e --- /dev/null +++ b/src/islands/calculators/DateDuration.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { daysBetween, ymdBetween, addDays, businessDaysBetween } from '@/tools/calculators/datedur.lib'; +import type { Lang } from '@/i18n/config'; + +function todayIso(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +const TR: Record> = { + en: { + intro: 'Find the time between two dates — in years/months/days, total days, and working days — or add/subtract days from a date. All in your browser.', + diff: 'Difference between dates', from: 'From', to: 'To', + breakdown: 'Duration', totalDays: 'Total days', workDays: 'Working days (Mon–Fri)', + y: 'years', mo: 'months', d: 'days', + addSub: 'Add or subtract days', startDate: 'Start date', offset: 'Days (+/−)', resultDate: 'Result date', + }, + id: { + intro: 'Cari selisih antara dua tanggal — dalam tahun/bulan/hari, total hari, dan hari kerja — atau tambah/kurangi hari dari sebuah tanggal. Semua di browser Anda.', + diff: 'Selisih antar tanggal', from: 'Dari', to: 'Sampai', + breakdown: 'Durasi', totalDays: 'Total hari', workDays: 'Hari kerja (Sen–Jum)', + y: 'tahun', mo: 'bulan', d: 'hari', + addSub: 'Tambah atau kurangi hari', startDate: 'Tanggal mulai', offset: 'Hari (+/−)', resultDate: 'Tanggal hasil', + }, +}; + +export default function DateDuration({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [from, setFrom] = useState(todayIso()); + const [to, setTo] = useState(addDays(todayIso(), 30)); + const [start, setStart] = useState(todayIso()); + const [offset, setOffset] = useState('90'); + + const total = daysBetween(from, to); + const ymd = ymdBetween(from, to); + const work = businessDaysBetween(from, to); + const resultDate = addDays(start, Number(offset) || 0); + const input = 'w-full border-2 border-border bg-muted p-2 text-sm tabular-nums'; + const valid = !Number.isNaN(total); + + return ( +
+

{t.intro}

+ +
+

{t.diff}

+
+ + +
+ {valid && ( +
+
+
{t.breakdown}
+
{ymd.years}y {ymd.months}m {ymd.days}d
+
{ymd.years} {t.y}, {ymd.months} {t.mo}, {ymd.days} {t.d}
+
+
+
{t.totalDays}
+
{Math.abs(total).toLocaleString()}
+
+
+
{t.workDays}
+
{work.toLocaleString()}
+
+
+ )} +
+ +
+

{t.addSub}

+
+ + +
+ {t.resultDate} +
{resultDate}
+
+
+
+
+ ); +} diff --git a/src/islands/calculators/GpaCalculator.tsx b/src/islands/calculators/GpaCalculator.tsx new file mode 100644 index 0000000..1e5b672 --- /dev/null +++ b/src/islands/calculators/GpaCalculator.tsx @@ -0,0 +1,75 @@ +import { useRef, useState } from 'react'; +import { Plus, X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { computeGpa, GRADE_POINTS, type Course } from '@/tools/calculators/gpa.lib'; +import type { Lang } from '@/i18n/config'; + +interface Row extends Course { id: number; name: string } + +const GRADES = Object.keys(GRADE_POINTS); + +const TR: Record> = { + en: { + intro: 'Add your courses with a letter grade and credit hours to get your weighted GPA on a 4.0 scale. Calculated in your browser.', + course: 'Course (optional)', grade: 'Grade', credits: 'Credits', add: 'Add course', + gpa: 'Your GPA', totalCredits: 'Total credits', coursePh: 'e.g. Calculus', + }, + id: { + intro: 'Tambahkan mata kuliah dengan nilai huruf dan jumlah SKS untuk mendapatkan IPK terbobot pada skala 4,0. Dihitung di browser Anda.', + course: 'Mata kuliah (opsional)', grade: 'Nilai', credits: 'SKS', add: 'Tambah mata kuliah', + gpa: 'IPK Anda', totalCredits: 'Total SKS', coursePh: 'mis. Kalkulus', + }, +}; + +export default function GpaCalculator({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [rows, setRows] = useState([ + { id: 1, name: '', grade: 'A', credits: 3 }, + { id: 2, name: '', grade: 'B+', credits: 4 }, + ]); + const nextId = useRef(3); + + const update = (id: number, patch: Partial) => + setRows(rs => rs.map(r => (r.id === id ? { ...r, ...patch } : r))); + const addRow = () => setRows(rs => [...rs, { id: nextId.current++, name: '', grade: 'A', credits: 3 }]); + const removeRow = (id: number) => setRows(rs => rs.filter(r => r.id !== id)); + + const { gpa, credits } = computeGpa(rows); + const input = 'border-2 border-border bg-muted p-2 text-sm tabular-nums'; + + return ( +
+

{t.intro}

+ +
+
+ {t.course}{t.grade}{t.credits} +
+ {rows.map(r => ( +
+ update(r.id, { name: e.target.value })} placeholder={t.coursePh} className={input} /> + + update(r.id, { credits: Number(e.target.value) || 0 })} inputMode="decimal" className={input} /> + +
+ ))} + +
+ +
+
+
{t.gpa}
+
{gpa.toFixed(2)}
+
+
+
{t.totalCredits}
+
{credits}
+
+
+
+ ); +} diff --git a/src/islands/calculators/ScientificCalc.tsx b/src/islands/calculators/ScientificCalc.tsx new file mode 100644 index 0000000..ace30d0 --- /dev/null +++ b/src/islands/calculators/ScientificCalc.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { evaluate, type AngleMode } from '@/tools/calculators/scientific.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { intro: 'A scientific calculator that runs in your browser. Type an expression or tap the keys; supports functions, powers and constants.', error: 'Error', clear: 'C' }, + id: { intro: 'Kalkulator ilmiah yang berjalan di browser Anda. Ketik ekspresi atau ketuk tombol; mendukung fungsi, pangkat, dan konstanta.', error: 'Error', clear: 'C' }, +}; + +/** Round away binary-float noise for display (e.g. 0.1+0.2). */ +function formatResult(n: number): string { + const r = Number(n.toPrecision(12)); + return String(r); +} + +// [label, token-to-insert or action] +type Key = { label: string; insert?: string; act?: 'eq' | 'clear' | 'back' }; +const KEYS: Key[][] = [ + [{ label: 'sin', insert: 'sin(' }, { label: 'cos', insert: 'cos(' }, { label: 'tan', insert: 'tan(' }, { label: '^', insert: '^' }, { label: '⌫', act: 'back' }], + [{ label: 'ln', insert: 'ln(' }, { label: 'log', insert: 'log(' }, { label: '√', insert: 'sqrt(' }, { label: '(', insert: '(' }, { label: ')', insert: ')' }], + [{ label: '7', insert: '7' }, { label: '8', insert: '8' }, { label: '9', insert: '9' }, { label: '÷', insert: '/' }, { label: 'π', insert: 'pi' }], + [{ label: '4', insert: '4' }, { label: '5', insert: '5' }, { label: '6', insert: '6' }, { label: '×', insert: '*' }, { label: 'e', insert: 'e' }], + [{ label: '1', insert: '1' }, { label: '2', insert: '2' }, { label: '3', insert: '3' }, { label: '−', insert: '-' }, { label: 'C', act: 'clear' }], + [{ label: '0', insert: '0' }, { label: '.', insert: '.' }, { label: 'abs', insert: 'abs(' }, { label: '+', insert: '+' }, { label: '=', act: 'eq' }], +]; + +export default function ScientificCalc({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [expr, setExpr] = useState(''); + const [result, setResult] = useState(null); + const [angle, setAngle] = useState('deg'); + + const equals = () => { + if (!expr.trim()) return; + try { setResult(formatResult(evaluate(expr, angle))); } + catch { setResult(t.error); } + }; + + const press = (k: Key) => { + if (k.act === 'eq') return equals(); + if (k.act === 'clear') { setExpr(''); setResult(null); return; } + if (k.act === 'back') { setExpr(e => e.slice(0, -1)); return; } + if (k.insert !== undefined) { setExpr(e => e + k.insert); setResult(null); } + }; + + return ( +
+

{t.intro}

+ +
+
+ {(['deg', 'rad'] as const).map(a => ( + + ))} +
+
+ +
+ { setExpr(e.target.value); setResult(null); }} + onKeyDown={e => { if (e.key === 'Enter') equals(); }} + placeholder="0" + aria-label="expression" + className="w-full bg-transparent text-right font-mono text-xl outline-none" + /> +
+ {result ?? ' '} +
+
+ +
+ {KEYS.flat().map((k, i) => ( + + ))} +
+
+ ); +} diff --git a/src/islands/calculators/TdeeCalculator.tsx b/src/islands/calculators/TdeeCalculator.tsx new file mode 100644 index 0000000..992c07c --- /dev/null +++ b/src/islands/calculators/TdeeCalculator.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { bmr, tdee, calorieGoals, ACTIVITY_FACTORS, type Sex, type Activity } from '@/tools/calculators/tdee.lib'; +import { lbToKg, ftInToCm } from '@/tools/calculators/bmi.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { + intro: 'Estimate your daily calorie needs (BMR and TDEE) with the Mifflin-St Jeor formula. Calculated entirely in your browser.', + metric: 'Metric', imperial: 'Imperial', sex: 'Sex', male: 'Male', female: 'Female', + age: 'Age', height: 'Height', weight: 'Weight', activity: 'Activity level', + bmr: 'BMR (at rest)', tdee: 'TDEE (maintenance)', perDay: 'kcal / day', + goals: 'Daily calories by goal', loseFast: 'Lose (−0.5 kg/wk)', lose: 'Lose (−0.25 kg/wk)', + maintain: 'Maintain', gain: 'Gain (+0.25 kg/wk)', gainFast: 'Gain (+0.5 kg/wk)', + sedentary: 'Sedentary (little/no exercise)', light: 'Light (1–3 days/wk)', moderate: 'Moderate (3–5 days/wk)', + active: 'Active (6–7 days/wk)', veryActive: 'Very active (hard daily / physical job)', + }, + id: { + intro: 'Perkirakan kebutuhan kalori harian Anda (BMR dan TDEE) dengan rumus Mifflin-St Jeor. Dihitung sepenuhnya di browser Anda.', + metric: 'Metrik', imperial: 'Imperial', sex: 'Jenis kelamin', male: 'Pria', female: 'Wanita', + age: 'Usia', height: 'Tinggi', weight: 'Berat', activity: 'Tingkat aktivitas', + bmr: 'BMR (istirahat)', tdee: 'TDEE (pemeliharaan)', perDay: 'kkal / hari', + goals: 'Kalori harian per tujuan', loseFast: 'Turun (−0,5 kg/mgg)', lose: 'Turun (−0,25 kg/mgg)', + maintain: 'Pertahankan', gain: 'Naik (+0,25 kg/mgg)', gainFast: 'Naik (+0,5 kg/mgg)', + sedentary: 'Rendah (sedikit/tanpa olahraga)', light: 'Ringan (1–3 hari/mgg)', moderate: 'Sedang (3–5 hari/mgg)', + active: 'Aktif (6–7 hari/mgg)', veryActive: 'Sangat aktif (berat harian / kerja fisik)', + }, +}; + +export default function TdeeCalculator({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [unit, setUnit] = useState<'metric' | 'imperial'>('metric'); + const [sex, setSex] = useState('male'); + const [age, setAge] = useState('30'); + const [cm, setCm] = useState('175'); + const [kg, setKg] = useState('70'); + const [ft, setFt] = useState('5'); + const [inch, setInch] = useState('9'); + const [lb, setLb] = useState('154'); + const [activity, setActivity] = useState('moderate'); + + const heightCm = unit === 'metric' ? Number(cm) || 0 : ftInToCm(Number(ft) || 0, Number(inch) || 0); + const weightKg = unit === 'metric' ? Number(kg) || 0 : lbToKg(Number(lb) || 0); + const ageN = Number(age) || 0; + const valid = heightCm > 0 && weightKg > 0 && ageN > 0; + const bmrVal = bmr(sex, weightKg, heightCm, ageN); + const tdeeVal = tdee(sex, weightKg, heightCm, ageN, activity); + const goals = calorieGoals(tdeeVal); + const input = 'w-full border-2 border-border bg-muted p-2 text-sm tabular-nums'; + const kcal = (v: number) => Math.round(v).toLocaleString(); + + return ( +
+

{t.intro}

+ +
+ {(['metric', 'imperial'] as const).map(u => ( + + ))} +
+ +
+ + + {unit === 'metric' ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + + + {valid && ( + <> +
+
+
{t.bmr}
+
{kcal(bmrVal)}
+
{t.perDay}
+
+
+
{t.tdee}
+
{kcal(tdeeVal)}
+
{t.perDay}
+
+
+
+
{t.goals}
+
    + {(['loseFast', 'lose', 'maintain', 'gain', 'gainFast'] as const).map(k => ( +
  • + {t[k]} + {kcal(goals[k])} {t.perDay} +
  • + ))} +
+
+ + )} +
+ ); +} diff --git a/src/registry/tool-i18n.ts b/src/registry/tool-i18n.ts index 43de4d1..584de4a 100644 --- a/src/registry/tool-i18n.ts +++ b/src/registry/tool-i18n.ts @@ -77,6 +77,11 @@ const ID_LABELS: Record = { "unit-converter": { name: "Konverter Satuan", summary: "Konversi panjang, massa, suhu, volume, kecepatan, dan lainnya" }, "pomodoro-timer": { name: "Timer Pomodoro", summary: "Timer fokus Pomodoro yang dapat diatur dengan waktu istirahat" }, "typing-test": { name: "Tes Kecepatan Mengetik", summary: "Ukur kecepatan mengetik (WPM) dan akurasi Anda" }, + "bmi-calculator": { name: "Kalkulator BMI", summary: "Hitung Indeks Massa Tubuh dan rentang berat sehat Anda" }, + "tdee-calculator": { name: "Kalkulator Kalori / TDEE", summary: "Perkirakan kalori harian (BMR & TDEE) untuk tujuan Anda" }, + "gpa-calculator": { name: "Kalkulator IPK", summary: "Hitung IPK terbobot pada skala 4,0" }, + "date-duration": { name: "Kalkulator Selisih Tanggal", summary: "Hari antara dua tanggal, atau tambah/kurangi hari" }, + "scientific-calculator": { name: "Kalkulator Ilmiah", summary: "Kalkulator ilmiah dengan fungsi, pangkat & konstanta" }, "kpr-calculator": { name: "Kalkulator KPR / Cicilan Rumah", summary: "Hitung cicilan bulanan KPR dan amortisasi" }, "zakat-calculator": { name: "Kalkulator Zakat", summary: "Hitung zakat maal dan zakat penghasilan (2,5%)" }, "thr-calculator": { name: "Kalkulator THR", summary: "Hitung THR (tunjangan hari raya), penuh atau prorata" }, diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 0e605e4..f20c96e 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -1025,6 +1025,91 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the timer works with no internet connection.' }, ], }, + 'bmi-calculator': { + title: 'Free BMI Calculator — Body Mass Index & Healthy Weight', + description: 'Calculate your BMI (Body Mass Index) from height and weight in metric or imperial, see your category and your healthy weight range. Runs in your browser.', + intro: 'This free BMI calculator works out your Body Mass Index from your height and weight, tells you which category it falls in (underweight, normal, overweight or obese), and shows the healthy weight range for your height. It supports both metric and imperial units and calculates everything in your browser.', + howTo: [ + 'Choose Metric or Imperial units.', + 'Enter your height and your weight.', + 'Read your BMI and category, shown instantly.', + 'Check the healthy weight range for your height below the result.', + ], + faqs: [ + { q: 'Is my data sent anywhere?', a: 'No. The calculation runs entirely in your browser — your height and weight never leave your device.' }, + { q: 'How is BMI calculated?', a: 'BMI is your weight in kilograms divided by your height in metres squared (kg/m²). The tool converts imperial units for you.' }, + { q: 'What do the categories mean?', a: 'BMI under 18.5 is underweight, 18.5–24.9 normal, 25–29.9 overweight, and 30+ obese. These are general guidelines and do not account for muscle mass or body composition.' }, + { q: 'Is BMI accurate for everyone?', a: 'BMI is a quick screening number, not a diagnosis. It can misclassify athletes and very muscular people. Treat it as a rough guide and consult a professional for advice.' }, + ], + }, + 'tdee-calculator': { + title: 'Free TDEE & Calorie Calculator — BMR and Daily Calories', + description: 'Calculate your BMR and TDEE (maintenance calories) with the Mifflin-St Jeor formula, plus calorie targets to lose, maintain or gain weight. In your browser.', + intro: 'This free calorie calculator estimates your BMR (calories burned at rest) and TDEE (total daily calories to maintain your weight) using the Mifflin-St Jeor equation, then suggests daily calorie targets for losing, maintaining or gaining weight. Everything is calculated on your device.', + howTo: [ + 'Choose metric or imperial units.', + 'Enter your sex, age, height and weight.', + 'Pick the activity level that matches your week.', + 'Read your BMR, TDEE and the calorie targets for each goal.', + ], + faqs: [ + { q: 'Is my data uploaded?', a: 'No. The calculation happens entirely in your browser — nothing you enter is sent to a server.' }, + { q: 'What is the difference between BMR and TDEE?', a: 'BMR is the calories your body burns at complete rest. TDEE is BMR multiplied by an activity factor — the calories you burn on a typical day, i.e. your maintenance calories.' }, + { q: 'Which formula does it use?', a: 'The Mifflin-St Jeor equation, which is widely regarded as one of the most accurate for estimating BMR from height, weight, age and sex.' }, + { q: 'How much should I eat to lose weight?', a: 'A deficit of about 250–500 kcal/day below your TDEE tends to lose roughly 0.25–0.5 kg per week. The tool shows those targets, but treat them as starting points.' }, + ], + }, + 'gpa-calculator': { + title: 'Free GPA Calculator — Weighted Grade Point Average (4.0)', + description: 'Calculate your weighted GPA on a 4.0 scale from your course grades and credit hours. Add as many courses as you like — all in your browser, nothing uploaded.', + intro: 'This free GPA calculator works out your grade point average on a 4.0 scale. Enter each course\'s letter grade and credit hours and it credit-weights them into an overall GPA, updating as you type. It runs entirely in your browser.', + howTo: [ + 'For each course, pick the letter grade and enter its credit hours.', + 'Click "Add course" to add more rows.', + 'Read your weighted GPA and total credits, updated instantly.', + 'Remove any row with the ✕ button.', + ], + faqs: [ + { q: 'Is my data private?', a: 'Yes. Everything is calculated in your browser — your grades never leave your device.' }, + { q: 'How is GPA weighted?', a: 'Each grade is converted to points (A = 4.0, B = 3.0, …), multiplied by the course credits, summed, and divided by the total credits — so higher-credit courses count more.' }, + { q: 'Which grade scale does it use?', a: 'A standard US 4.0 scale with pluses and minuses (A+/A = 4.0, A− = 3.7, B+ = 3.3, and so on down to F = 0.0).' }, + { q: 'Can I use it for a semester or cumulative GPA?', a: 'Both — enter one semester\'s courses for a semester GPA, or all your courses for a cumulative GPA.' }, + ], + }, + 'date-duration': { + title: 'Date Duration Calculator — Days Between Dates & Add Days', + description: 'Find the time between two dates in years, months and days, total days and working days — or add/subtract days from a date. Free and runs in your browser.', + intro: 'This free date calculator does two things: it finds the duration between two dates (as years/months/days, total days and working days), and it adds or subtracts a number of days from a date to find the resulting date. Everything is worked out in your browser.', + howTo: [ + 'Pick a From and To date to see the duration between them.', + 'Read the breakdown: years/months/days, total days, and working days (Mon–Fri).', + 'To project a date, enter a start date and a number of days (use a minus sign to go back).', + 'The resulting date appears instantly.', + ], + faqs: [ + { q: 'Is anything sent to a server?', a: 'No. All the date math runs in your browser — nothing is uploaded.' }, + { q: 'Are both dates counted?', a: 'The total-days figure is the number of days from the first date to the second. Working days count each Monday–Friday in the range, inclusive of both endpoints.' }, + { q: 'Can I count backwards?', a: 'Yes. For "add or subtract days", enter a negative number to find a date in the past.' }, + { q: 'How are months counted?', a: 'The years/months/days breakdown uses calendar months, borrowing the length of the previous month when the end day is earlier than the start day.' }, + ], + }, + 'scientific-calculator': { + title: 'Free Scientific Calculator Online — Trig, Logs & Powers', + description: 'A free online scientific calculator with trigonometry, logarithms, square roots, powers and constants (π, e). Type or tap; runs entirely in your browser.', + intro: 'This free scientific calculator evaluates expressions with functions (sin, cos, tan, ln, log, √), powers, parentheses and constants like π and e. Type an expression or tap the keypad, switch between degrees and radians, and get the answer instantly — all in your browser.', + howTo: [ + 'Type an expression in the display, or tap the keypad buttons.', + 'Use the DEG/RAD toggle to set the angle mode for trig functions.', + 'Press = (or Enter) to evaluate; use C to clear and ⌫ to delete.', + 'Combine functions and parentheses, e.g. sqrt(2)^2 or sin(30).', + ], + faqs: [ + { q: 'Does it run offline / privately?', a: 'Yes. The calculator evaluates expressions in your browser with no server call, so it works offline and nothing you type is sent anywhere.' }, + { q: 'Which functions are supported?', a: 'sin, cos, tan, asin, acos, atan, ln, log (base 10), sqrt, abs and exp, plus the constants π and e, powers with ^, and parentheses.' }, + { q: 'Degrees or radians?', a: 'Both. Use the DEG/RAD toggle before evaluating trig functions — for example sin(90) in degrees gives 1.' }, + { q: 'Is it safe? Does it use eval?', a: 'It uses a small custom parser (no JavaScript eval), so it only ever does arithmetic — it cannot run arbitrary code.' }, + ], + }, 'typing-test': { title: 'Free Typing Speed Test — Words Per Minute (WPM) & Accuracy', description: 'Test your typing speed and accuracy online. The timer starts on your first keystroke and shows live words-per-minute and accuracy. Free, private — nothing is uploaded.', @@ -4073,6 +4158,91 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat timer bekerja tanpa koneksi internet.' }, ], }, + 'bmi-calculator': { + title: 'Kalkulator BMI Gratis — Indeks Massa Tubuh & Berat Sehat', + description: 'Hitung BMI (Indeks Massa Tubuh) dari tinggi dan berat badan dalam satuan metrik atau imperial, lihat kategori dan rentang berat sehat Anda. Berjalan di browser Anda.', + intro: 'Kalkulator BMI gratis ini menghitung Indeks Massa Tubuh dari tinggi dan berat badan Anda, memberi tahu kategorinya (kurus, normal, berlebih, atau obesitas), dan menampilkan rentang berat sehat untuk tinggi Anda. Mendukung satuan metrik dan imperial serta menghitung semuanya di browser Anda.', + howTo: [ + 'Pilih satuan Metrik atau Imperial.', + 'Masukkan tinggi dan berat badan Anda.', + 'Baca BMI dan kategori Anda yang tampil seketika.', + 'Lihat rentang berat sehat untuk tinggi Anda di bawah hasil.', + ], + faqs: [ + { q: 'Apakah data saya dikirim ke mana pun?', a: 'Tidak. Perhitungan berjalan sepenuhnya di browser Anda — tinggi dan berat Anda tidak pernah keluar dari perangkat.' }, + { q: 'Bagaimana BMI dihitung?', a: 'BMI adalah berat dalam kilogram dibagi tinggi dalam meter kuadrat (kg/m²). Tool mengonversi satuan imperial untuk Anda.' }, + { q: 'Apa arti kategorinya?', a: 'BMI di bawah 18,5 kurus, 18,5–24,9 normal, 25–29,9 berlebih, dan 30+ obesitas. Ini panduan umum dan tidak memperhitungkan massa otot atau komposisi tubuh.' }, + { q: 'Apakah BMI akurat untuk semua orang?', a: 'BMI adalah angka skrining cepat, bukan diagnosis. BMI bisa salah mengklasifikasikan atlet dan orang yang sangat berotot. Anggap sebagai panduan kasar dan konsultasikan dengan profesional.' }, + ], + }, + 'tdee-calculator': { + title: 'Kalkulator Kalori & TDEE Gratis — BMR dan Kalori Harian', + description: 'Hitung BMR dan TDEE (kalori pemeliharaan) dengan rumus Mifflin-St Jeor, plus target kalori untuk menurunkan, mempertahankan, atau menaikkan berat. Di browser Anda.', + intro: 'Kalkulator kalori gratis ini memperkirakan BMR (kalori saat istirahat) dan TDEE (total kalori harian untuk mempertahankan berat) memakai rumus Mifflin-St Jeor, lalu menyarankan target kalori harian untuk menurunkan, mempertahankan, atau menaikkan berat. Semua dihitung di perangkat Anda.', + howTo: [ + 'Pilih satuan metrik atau imperial.', + 'Masukkan jenis kelamin, usia, tinggi, dan berat.', + 'Pilih tingkat aktivitas yang sesuai dengan minggu Anda.', + 'Baca BMR, TDEE, dan target kalori untuk tiap tujuan.', + ], + faqs: [ + { q: 'Apakah data saya diunggah?', a: 'Tidak. Perhitungan terjadi sepenuhnya di browser Anda — tidak ada yang Anda masukkan dikirim ke server.' }, + { q: 'Apa beda BMR dan TDEE?', a: 'BMR adalah kalori yang dibakar tubuh saat istirahat total. TDEE adalah BMR dikali faktor aktivitas — kalori yang Anda bakar pada hari biasa, yaitu kalori pemeliharaan.' }, + { q: 'Rumus apa yang dipakai?', a: 'Persamaan Mifflin-St Jeor, yang dianggap salah satu paling akurat untuk memperkirakan BMR dari tinggi, berat, usia, dan jenis kelamin.' }, + { q: 'Berapa yang harus dimakan untuk turun berat?', a: 'Defisit sekitar 250–500 kkal/hari di bawah TDEE cenderung menurunkan sekitar 0,25–0,5 kg per minggu. Tool menampilkan target itu, tetapi anggap sebagai titik awal.' }, + ], + }, + 'gpa-calculator': { + title: 'Kalkulator IPK Gratis — Rata-rata Terbobot (Skala 4,0)', + description: 'Hitung IPK terbobot pada skala 4,0 dari nilai mata kuliah dan SKS Anda. Tambahkan sebanyak mungkin mata kuliah — semua di browser Anda, tidak ada yang diunggah.', + intro: 'Kalkulator IPK gratis ini menghitung rata-rata nilai pada skala 4,0. Masukkan nilai huruf dan SKS tiap mata kuliah, lalu tool membobotnya berdasarkan SKS menjadi IPK keseluruhan, diperbarui saat Anda mengetik. Berjalan sepenuhnya di browser Anda.', + howTo: [ + 'Untuk tiap mata kuliah, pilih nilai huruf dan masukkan jumlah SKS.', + 'Klik "Tambah mata kuliah" untuk menambah baris.', + 'Baca IPK terbobot dan total SKS Anda, diperbarui seketika.', + 'Hapus baris mana pun dengan tombol ✕.', + ], + faqs: [ + { q: 'Apakah data saya privat?', a: 'Ya. Semua dihitung di browser Anda — nilai Anda tidak pernah keluar dari perangkat.' }, + { q: 'Bagaimana IPK dibobot?', a: 'Tiap nilai diubah ke poin (A = 4,0, B = 3,0, …), dikali SKS mata kuliah, dijumlahkan, lalu dibagi total SKS — jadi mata kuliah ber-SKS besar lebih berpengaruh.' }, + { q: 'Skala nilai apa yang dipakai?', a: 'Skala 4,0 standar dengan plus dan minus (A+/A = 4,0, A− = 3,7, B+ = 3,3, dan seterusnya hingga F = 0,0).' }, + { q: 'Bisa untuk IPK semester atau kumulatif?', a: 'Keduanya — masukkan mata kuliah satu semester untuk IPK semester, atau semua mata kuliah untuk IPK kumulatif.' }, + ], + }, + 'date-duration': { + title: 'Kalkulator Selisih Tanggal — Hari Antar Tanggal & Tambah Hari', + description: 'Cari selisih dua tanggal dalam tahun, bulan, hari, total hari, dan hari kerja — atau tambah/kurangi hari dari sebuah tanggal. Gratis dan berjalan di browser Anda.', + intro: 'Kalkulator tanggal gratis ini melakukan dua hal: mencari durasi antara dua tanggal (sebagai tahun/bulan/hari, total hari, dan hari kerja), dan menambah atau mengurangi sejumlah hari dari sebuah tanggal untuk menemukan tanggal hasilnya. Semua dihitung di browser Anda.', + howTo: [ + 'Pilih tanggal Dari dan Sampai untuk melihat durasi di antaranya.', + 'Baca rinciannya: tahun/bulan/hari, total hari, dan hari kerja (Sen–Jum).', + 'Untuk memproyeksikan tanggal, masukkan tanggal mulai dan jumlah hari (pakai tanda minus untuk mundur).', + 'Tanggal hasil muncul seketika.', + ], + faqs: [ + { q: 'Apakah ada yang dikirim ke server?', a: 'Tidak. Semua perhitungan tanggal berjalan di browser Anda — tidak ada yang diunggah.' }, + { q: 'Apakah kedua tanggal dihitung?', a: 'Angka total hari adalah jumlah hari dari tanggal pertama ke kedua. Hari kerja menghitung tiap Senin–Jumat dalam rentang, termasuk kedua ujungnya.' }, + { q: 'Bisakah menghitung mundur?', a: 'Bisa. Untuk "tambah atau kurangi hari", masukkan angka negatif untuk menemukan tanggal di masa lalu.' }, + { q: 'Bagaimana bulan dihitung?', a: 'Rincian tahun/bulan/hari memakai bulan kalender, meminjam panjang bulan sebelumnya saat tanggal akhir lebih awal dari tanggal mulai.' }, + ], + }, + 'scientific-calculator': { + title: 'Kalkulator Ilmiah Online Gratis — Trig, Log & Pangkat', + description: 'Kalkulator ilmiah online gratis dengan trigonometri, logaritma, akar kuadrat, pangkat, dan konstanta (π, e). Ketik atau ketuk; berjalan sepenuhnya di browser Anda.', + intro: 'Kalkulator ilmiah gratis ini mengevaluasi ekspresi dengan fungsi (sin, cos, tan, ln, log, √), pangkat, tanda kurung, dan konstanta seperti π dan e. Ketik ekspresi atau ketuk papan tombol, beralih antara derajat dan radian, dan dapatkan jawaban seketika — semua di browser Anda.', + howTo: [ + 'Ketik ekspresi di layar, atau ketuk tombol papan tombol.', + 'Pakai sakelar DEG/RAD untuk mengatur mode sudut fungsi trigonometri.', + 'Tekan = (atau Enter) untuk menghitung; pakai C untuk menghapus dan ⌫ untuk menghapus satu karakter.', + 'Gabungkan fungsi dan tanda kurung, mis. sqrt(2)^2 atau sin(30).', + ], + faqs: [ + { q: 'Apakah berjalan offline / privat?', a: 'Ya. Kalkulator mengevaluasi ekspresi di browser Anda tanpa panggilan server, jadi berfungsi offline dan tidak ada yang Anda ketik dikirim ke mana pun.' }, + { q: 'Fungsi apa yang didukung?', a: 'sin, cos, tan, asin, acos, atan, ln, log (basis 10), sqrt, abs, dan exp, plus konstanta π dan e, pangkat dengan ^, dan tanda kurung.' }, + { q: 'Derajat atau radian?', a: 'Keduanya. Pakai sakelar DEG/RAD sebelum menghitung fungsi trigonometri — misalnya sin(90) dalam derajat menghasilkan 1.' }, + { q: 'Apakah aman? Apakah pakai eval?', a: 'Tool memakai parser khusus kecil (tanpa eval JavaScript), jadi hanya melakukan aritmetika — tidak bisa menjalankan kode sembarangan.' }, + ], + }, 'typing-test': { title: 'Tes Kecepatan Mengetik Gratis — Kata Per Menit (KPM) & Akurasi', description: 'Uji kecepatan dan akurasi mengetik Anda online. Timer dimulai pada ketukan pertama dan menampilkan kata per menit serta akurasi langsung. Gratis, privat — tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index c7c3a59..ea12c2a 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2, AlarmClock } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2, AlarmClock, Scale, Flame, GraduationCap } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -773,6 +773,61 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/calculators/TypingTest'), status: 'beta' }, + { + id: 'bmi-calculator', + name: 'BMI Calculator', + category: 'Calculators', + route: '/tools/bmi-calculator', + keywords: ['bmi calculator', 'body mass index', 'bmi', 'healthy weight', 'kalkulator bmi', 'indeks massa tubuh', 'metric', 'imperial'], + icon: Scale, + summary: 'Calculate your Body Mass Index and healthy weight range', + load: () => import('@/islands/calculators/BmiCalculator'), + status: 'beta' + }, + { + id: 'tdee-calculator', + name: 'Calorie / TDEE Calculator', + category: 'Calculators', + route: '/tools/tdee-calculator', + keywords: ['tdee calculator', 'calorie calculator', 'bmr calculator', 'maintenance calories', 'mifflin st jeor', 'kalkulator kalori', 'kebutuhan kalori', 'macro'], + icon: Flame, + summary: 'Estimate daily calories (BMR & TDEE) for your goals', + load: () => import('@/islands/calculators/TdeeCalculator'), + status: 'beta' + }, + { + id: 'gpa-calculator', + name: 'GPA Calculator', + category: 'Calculators', + route: '/tools/gpa-calculator', + keywords: ['gpa calculator', 'grade point average', 'college gpa', 'weighted gpa', '4.0 scale', 'kalkulator ipk', 'nilai', 'sks'], + icon: GraduationCap, + summary: 'Compute your weighted GPA on a 4.0 scale', + load: () => import('@/islands/calculators/GpaCalculator'), + status: 'beta' + }, + { + id: 'date-duration', + name: 'Date Duration Calculator', + category: 'Calculators', + route: '/tools/date-duration', + keywords: ['date duration', 'days between dates', 'date difference', 'add days to date', 'working days', 'business days', 'selisih tanggal', 'hitung hari'], + icon: CalendarClock, + summary: 'Days between two dates, or add/subtract days', + load: () => import('@/islands/calculators/DateDuration'), + status: 'beta' + }, + { + id: 'scientific-calculator', + name: 'Scientific Calculator', + category: 'Calculators', + route: '/tools/scientific-calculator', + keywords: ['scientific calculator', 'calculator', 'trigonometry', 'sin cos tan', 'logarithm', 'square root', 'exponent', 'kalkulator ilmiah'], + icon: Calculator, + summary: 'Scientific calculator with functions, powers & constants', + load: () => import('@/islands/calculators/ScientificCalc'), + status: 'beta' + }, { id: 'kpr-calculator', name: 'KPR / Mortgage Calculator', diff --git a/src/tools/calculators/bmi.lib.test.ts b/src/tools/calculators/bmi.lib.test.ts new file mode 100644 index 0000000..e54a566 --- /dev/null +++ b/src/tools/calculators/bmi.lib.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { computeBmi, bmiCategory, healthyWeightRange, lbToKg, ftInToCm } from './bmi.lib'; + +describe('computeBmi', () => { + it('computes kg/m²', () => { + expect(computeBmi(70, 175)).toBeCloseTo(22.857, 2); + }); + it('returns 0 for non-positive height', () => { + expect(computeBmi(70, 0)).toBe(0); + }); +}); + +describe('bmiCategory', () => { + it.each([ + [17, 'underweight'], + [22, 'normal'], + [27, 'overweight'], + [33, 'obese'], + [18.5, 'normal'], + [25, 'overweight'], + ])('classifies %d as %s', (bmi, cat) => { + expect(bmiCategory(bmi)).toBe(cat); + }); +}); + +describe('healthyWeightRange', () => { + it('spans BMI 18.5–24.9 for the height', () => { + const r = healthyWeightRange(175); + expect(r.min).toBeCloseTo(56.66, 1); + expect(r.max).toBeCloseTo(76.26, 1); + }); +}); + +describe('unit conversions', () => { + it('converts pounds to kg', () => { + expect(lbToKg(154)).toBeCloseTo(69.85, 1); + }); + it('converts feet+inches to cm', () => { + expect(ftInToCm(5, 9)).toBeCloseTo(175.26, 1); + }); +}); diff --git a/src/tools/calculators/bmi.lib.ts b/src/tools/calculators/bmi.lib.ts new file mode 100644 index 0000000..eadb13c --- /dev/null +++ b/src/tools/calculators/bmi.lib.ts @@ -0,0 +1,32 @@ +/** Pure Body Mass Index math. Unit conversions live in the island. */ + +export type BmiCategory = 'underweight' | 'normal' | 'overweight' | 'obese'; + +/** BMI from weight (kg) and height (cm). Returns 0 for non-positive height. */ +export function computeBmi(kg: number, cm: number): number { + const m = cm / 100; + return m > 0 ? kg / (m * m) : 0; +} + +export function bmiCategory(bmi: number): BmiCategory { + if (bmi < 18.5) return 'underweight'; + if (bmi < 25) return 'normal'; + if (bmi < 30) return 'overweight'; + return 'obese'; +} + +/** The healthy weight range (kg) for a given height, using BMI 18.5–24.9. */ +export function healthyWeightRange(cm: number): { min: number; max: number } { + const m = cm / 100; + return { min: 18.5 * m * m, max: 24.9 * m * m }; +} + +/** Pounds → kilograms. */ +export function lbToKg(lb: number): number { + return lb * 0.45359237; +} + +/** Feet + inches → centimetres. */ +export function ftInToCm(ft: number, inch: number): number { + return (ft * 12 + inch) * 2.54; +} diff --git a/src/tools/calculators/datedur.lib.test.ts b/src/tools/calculators/datedur.lib.test.ts new file mode 100644 index 0000000..e9d2be9 --- /dev/null +++ b/src/tools/calculators/datedur.lib.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { daysBetween, ymdBetween, addDays, businessDaysBetween } from './datedur.lib'; + +describe('daysBetween', () => { + it('counts whole days forward', () => { + expect(daysBetween('2026-01-01', '2026-01-08')).toBe(7); + }); + it('is negative when b precedes a', () => { + expect(daysBetween('2026-01-08', '2026-01-01')).toBe(-7); + }); + it('spans a leap day', () => { + expect(daysBetween('2024-02-28', '2024-03-01')).toBe(2); + }); +}); + +describe('ymdBetween', () => { + it('breaks a span into y/m/d', () => { + expect(ymdBetween('2026-01-15', '2026-03-20')).toEqual({ years: 0, months: 2, days: 5 }); + }); + it('borrows across a month when the end day is smaller', () => { + // Jan 31 → Mar 1: 1 month (to Feb 28/29) leaves a couple of days. + const r = ymdBetween('2026-01-31', '2026-03-01'); + expect(r.years).toBe(0); + expect(r.months).toBe(1); + }); + it('is order-independent', () => { + expect(ymdBetween('2026-03-20', '2026-01-15')).toEqual({ years: 0, months: 2, days: 5 }); + }); + it('handles multi-year spans', () => { + expect(ymdBetween('2020-06-10', '2023-06-10')).toEqual({ years: 3, months: 0, days: 0 }); + }); +}); + +describe('addDays', () => { + it('rolls over a month boundary', () => { + expect(addDays('2026-01-31', 1)).toBe('2026-02-01'); + }); + it('subtracts with a negative offset', () => { + expect(addDays('2026-03-01', -1)).toBe('2026-02-28'); + }); +}); + +describe('businessDaysBetween', () => { + it('counts a full Mon–Fri week as 5', () => { + expect(businessDaysBetween('2026-01-05', '2026-01-09')).toBe(5); // Mon–Fri + }); + it('excludes the weekend', () => { + expect(businessDaysBetween('2026-01-05', '2026-01-11')).toBe(5); // Mon–Sun + }); +}); diff --git a/src/tools/calculators/datedur.lib.ts b/src/tools/calculators/datedur.lib.ts new file mode 100644 index 0000000..ce052ef --- /dev/null +++ b/src/tools/calculators/datedur.lib.ts @@ -0,0 +1,69 @@ +/** + * Pure date-difference math. All functions take/return `YYYY-MM-DD` strings and + * work in UTC internally, so results never shift with the local timezone. + */ + +const DAY = 86_400_000; + +/** Parse `YYYY-MM-DD` to a UTC epoch (ms), or NaN if malformed. */ +function parseUtc(iso: string): number { + return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? Date.parse(iso + 'T00:00:00Z') : NaN; +} + +/** Whole days from `a` to `b` (negative if b is before a). */ +export function daysBetween(a: string, b: string): number { + const da = parseUtc(a), db = parseUtc(b); + if (Number.isNaN(da) || Number.isNaN(db)) return NaN; + return Math.round((db - da) / DAY); +} + +function daysInMonth(year: number, monthIndex0: number): number { + // monthIndex0 may be -1 (Dec of prev year) or 12 (Jan of next) — Date normalizes it. + return new Date(Date.UTC(year, monthIndex0 + 1, 0)).getUTCDate(); +} + +/** + * Calendar breakdown (years/months/days) of the span between two dates. Order of + * the arguments doesn't matter; the magnitude is returned. + */ +export function ymdBetween(a: string, b: string): { years: number; months: number; days: number } { + let da = parseUtc(a), db = parseUtc(b); + if (Number.isNaN(da) || Number.isNaN(db)) return { years: NaN, months: NaN, days: NaN }; + if (da > db) [da, db] = [db, da]; + const s = new Date(da), e = new Date(db); + + let years = e.getUTCFullYear() - s.getUTCFullYear(); + let months = e.getUTCMonth() - s.getUTCMonth(); + let days = e.getUTCDate() - s.getUTCDate(); + + if (days < 0) { + months -= 1; + // Borrow the length of the month before the end month. + days += daysInMonth(e.getUTCFullYear(), e.getUTCMonth() - 1); + } + if (months < 0) { + years -= 1; + months += 12; + } + return { years, months, days }; +} + +/** Add (or subtract, with a negative n) whole days to a date. */ +export function addDays(iso: string, n: number): string { + const t = parseUtc(iso); + if (Number.isNaN(t)) return iso; + return new Date(t + n * DAY).toISOString().slice(0, 10); +} + +/** Count of weekdays (Mon–Fri) between two dates, inclusive of both endpoints. */ +export function businessDaysBetween(a: string, b: string): number { + let da = parseUtc(a), db = parseUtc(b); + if (Number.isNaN(da) || Number.isNaN(db)) return NaN; + if (da > db) [da, db] = [db, da]; + let count = 0; + for (let t = da; t <= db; t += DAY) { + const dow = new Date(t).getUTCDay(); // 0 Sun … 6 Sat + if (dow !== 0 && dow !== 6) count++; + } + return count; +} diff --git a/src/tools/calculators/gpa.lib.test.ts b/src/tools/calculators/gpa.lib.test.ts new file mode 100644 index 0000000..671c662 --- /dev/null +++ b/src/tools/calculators/gpa.lib.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { gradeToPoints, computeGpa } from './gpa.lib'; + +describe('gradeToPoints', () => { + it.each([ + ['A', 4.0], ['a', 4.0], ['A-', 3.7], ['B+', 3.3], ['F', 0.0], + ])('%s → %d', (g, p) => { + expect(gradeToPoints(g)).toBe(p); + }); + it('returns null for unknown grades', () => { + expect(gradeToPoints('Z')).toBeNull(); + }); +}); + +describe('computeGpa', () => { + it('credit-weights the grade points', () => { + // A(3cr)=12, B(4cr)=12 → 24/7 = 3.4286 + const r = computeGpa([{ grade: 'A', credits: 3 }, { grade: 'B', credits: 4 }]); + expect(r.credits).toBe(7); + expect(r.gpa).toBeCloseTo(3.4286, 3); + }); + it('ignores unknown grades and zero-credit rows', () => { + const r = computeGpa([{ grade: 'A', credits: 3 }, { grade: 'Z', credits: 3 }, { grade: 'B', credits: 0 }]); + expect(r.credits).toBe(3); + expect(r.gpa).toBe(4.0); + }); + it('is 0 when no valid rows', () => { + expect(computeGpa([]).gpa).toBe(0); + }); +}); diff --git a/src/tools/calculators/gpa.lib.ts b/src/tools/calculators/gpa.lib.ts new file mode 100644 index 0000000..eaafd1c --- /dev/null +++ b/src/tools/calculators/gpa.lib.ts @@ -0,0 +1,37 @@ +/** Pure GPA math on a 4.0 scale. */ + +export interface Course { + grade: string; + credits: number; +} + +/** Standard US letter-grade → grade-point mapping (4.0 scale). */ +export const GRADE_POINTS: Record = { + 'A+': 4.0, A: 4.0, 'A-': 3.7, + 'B+': 3.3, B: 3.0, 'B-': 2.7, + 'C+': 2.3, C: 2.0, 'C-': 1.7, + 'D+': 1.3, D: 1.0, 'D-': 0.7, + F: 0.0, +}; + +/** Grade points for a letter grade, or null if unrecognized. */ +export function gradeToPoints(grade: string): number | null { + const key = grade.trim().toUpperCase(); + return key in GRADE_POINTS ? GRADE_POINTS[key] : null; +} + +/** + * Credit-weighted GPA. Rows with an unknown grade or non-positive credits are + * ignored, so a partially-filled form still gives a sensible running GPA. + */ +export function computeGpa(courses: Course[]): { gpa: number; credits: number } { + let points = 0; + let credits = 0; + for (const c of courses) { + const p = gradeToPoints(c.grade); + if (p === null || !(c.credits > 0)) continue; + points += p * c.credits; + credits += c.credits; + } + return { gpa: credits > 0 ? points / credits : 0, credits }; +} diff --git a/src/tools/calculators/scientific.lib.test.ts b/src/tools/calculators/scientific.lib.test.ts new file mode 100644 index 0000000..77afad0 --- /dev/null +++ b/src/tools/calculators/scientific.lib.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { evaluate } from './scientific.lib'; + +describe('evaluate', () => { + it.each([ + ['1+2*3', 7], + ['(1+2)*3', 9], + ['2^3^2', 512], // right-associative + ['-3+5', 2], + ['-(2+3)', -5], + ['10/4', 2.5], + ['2*-3', -6], + ['sqrt(16)', 4], + ['abs(-7)', 7], + ['2*pi', 2 * Math.PI], + ['ln(e)', 1], + ['log(1000)', 3], + ['1e3+1', 1001], + ])('evaluates %s = %d', (expr, expected) => { + expect(evaluate(expr)).toBeCloseTo(expected, 10); + }); + + it('honors degree mode for trig', () => { + expect(evaluate('sin(90)', 'deg')).toBeCloseTo(1, 10); + expect(evaluate('cos(180)', 'deg')).toBeCloseTo(-1, 10); + expect(evaluate('asin(1)', 'deg')).toBeCloseTo(90, 10); + }); + + it('uses radians by default', () => { + expect(evaluate('sin(0)')).toBeCloseTo(0, 10); + }); + + it('accepts unicode operators', () => { + expect(evaluate('6×7')).toBe(42); + expect(evaluate('8÷2')).toBe(4); + }); + + it.each(['', '1+', '(1+2', '1+2)', 'foo(2)', '1/0', '2**3'])('throws on %s', (expr) => { + expect(() => evaluate(expr)).toThrow(); + }); +}); diff --git a/src/tools/calculators/scientific.lib.ts b/src/tools/calculators/scientific.lib.ts new file mode 100644 index 0000000..b4d26d1 --- /dev/null +++ b/src/tools/calculators/scientific.lib.ts @@ -0,0 +1,154 @@ +/** + * A small, safe arithmetic expression evaluator (no `eval`). Supports + - * / ^, + * parentheses, unary minus, constants (pi, e) and functions (sin, cos, tan, + * asin, acos, atan, ln, log, sqrt, abs, exp). Trig respects an angle mode. + * + * Pipeline: tokenize → shunting-yard to RPN → evaluate RPN. + */ + +export type AngleMode = 'deg' | 'rad'; + +const CONSTANTS: Record = { pi: Math.PI, e: Math.E }; +const FUNCTIONS: Record number> = { + sin: Math.sin, cos: Math.cos, tan: Math.tan, + asin: Math.asin, acos: Math.acos, atan: Math.atan, + ln: Math.log, log: Math.log10, sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp, +}; +const TRIG = new Set(['sin', 'cos', 'tan']); +const INV_TRIG = new Set(['asin', 'acos', 'atan']); + +type TokType = 'num' | 'op' | 'func' | 'const' | 'lparen' | 'rparen'; +interface Tok { type: TokType; value: string } + +const PREC: Record = { '+': 2, '-': 2, '*': 3, '/': 3, '^': 4, neg: 5 }; +const RIGHT_ASSOC = new Set(['^', 'neg']); + +function tokenize(expr: string): Tok[] { + const s = expr.replace(/\s+/g, '').replace(/×/g, '*').replace(/÷/g, '/').replace(/π/g, 'pi'); + const toks: Tok[] = []; + let i = 0; + while (i < s.length) { + const c = s[i]; + if (/[0-9.]/.test(c)) { + let j = i + 1; + while (j < s.length && /[0-9.]/.test(s[j])) j++; + // Scientific notation: 1e3, 2.5e-4 + if (s[j] === 'e' && /[0-9.+-]/.test(s[j + 1] ?? '')) { + j++; + if (s[j] === '+' || s[j] === '-') j++; + while (j < s.length && /[0-9]/.test(s[j])) j++; + } + toks.push({ type: 'num', value: s.slice(i, j) }); + i = j; + continue; + } + if (/[a-zA-Z]/.test(c)) { + let j = i + 1; + while (j < s.length && /[a-zA-Z0-9]/.test(s[j])) j++; + const name = s.slice(i, j).toLowerCase(); + if (name in FUNCTIONS) toks.push({ type: 'func', value: name }); + else if (name in CONSTANTS) toks.push({ type: 'const', value: name }); + else throw new Error(`Unknown name: ${name}`); + i = j; + continue; + } + if ('+-*/^'.includes(c)) { toks.push({ type: 'op', value: c }); i++; continue; } + if (c === '(') { toks.push({ type: 'lparen', value: c }); i++; continue; } + if (c === ')') { toks.push({ type: 'rparen', value: c }); i++; continue; } + throw new Error(`Unexpected character: ${c}`); + } + return toks; +} + +function toRpn(toks: Tok[]): Tok[] { + const out: Tok[] = []; + const stack: Tok[] = []; + let prev: Tok | null = null; + for (const tok of toks) { + if (tok.type === 'num' || tok.type === 'const') { + out.push(tok); + } else if (tok.type === 'func') { + stack.push(tok); + } else if (tok.type === 'op') { + const unary = (tok.value === '-' || tok.value === '+') + && (prev === null || prev.type === 'op' || prev.type === 'lparen'); + if (unary) { + if (tok.value === '-') stack.push({ type: 'op', value: 'neg' }); + // unary '+' is a no-op + } else { + while (stack.length) { + const top = stack[stack.length - 1]; + if (top.type !== 'op') break; + const p = PREC[top.value], q = PREC[tok.value]; + if (p > q || (p === q && !RIGHT_ASSOC.has(tok.value))) out.push(stack.pop()!); + else break; + } + stack.push(tok); + } + } else if (tok.type === 'lparen') { + stack.push(tok); + } else { + // rparen + while (stack.length && stack[stack.length - 1].type !== 'lparen') out.push(stack.pop()!); + if (!stack.length) throw new Error('Mismatched parentheses'); + stack.pop(); // discard the '(' + if (stack.length && stack[stack.length - 1].type === 'func') out.push(stack.pop()!); + } + prev = tok; + } + while (stack.length) { + const t = stack.pop()!; + if (t.type === 'lparen') throw new Error('Mismatched parentheses'); + out.push(t); + } + return out; +} + +function applyOp(op: string, a: number, b: number): number { + switch (op) { + case '+': return a + b; + case '-': return a - b; + case '*': return a * b; + case '/': return a / b; + case '^': return Math.pow(a, b); + default: throw new Error(`Unknown operator: ${op}`); + } +} + +function evalRpn(rpn: Tok[], angle: AngleMode): number { + const st: number[] = []; + for (const tok of rpn) { + if (tok.type === 'num') { + const n = Number(tok.value); + if (Number.isNaN(n)) throw new Error(`Invalid number: ${tok.value}`); + st.push(n); + } else if (tok.type === 'const') { + st.push(CONSTANTS[tok.value]); + } else if (tok.type === 'func') { + const a = st.pop(); + if (a === undefined) throw new Error('Missing function argument'); + const x = angle === 'deg' && TRIG.has(tok.value) ? (a * Math.PI) / 180 : a; + let r = FUNCTIONS[tok.value](x); + if (angle === 'deg' && INV_TRIG.has(tok.value)) r = (r * 180) / Math.PI; + st.push(r); + } else if (tok.value === 'neg') { + const a = st.pop(); + if (a === undefined) throw new Error('Missing operand'); + st.push(-a); + } else { + const b = st.pop(), a = st.pop(); + if (a === undefined || b === undefined) throw new Error('Missing operand'); + st.push(applyOp(tok.value, a, b)); + } + } + if (st.length !== 1) throw new Error('Invalid expression'); + return st[0]; +} + +/** Evaluate an arithmetic expression. Throws on malformed input. */ +export function evaluate(expr: string, angle: AngleMode = 'rad'): number { + if (!expr.trim()) throw new Error('Empty expression'); + const result = evalRpn(toRpn(tokenize(expr)), angle); + if (!Number.isFinite(result)) throw new Error('Result is not a finite number'); + return result; +} diff --git a/src/tools/calculators/tdee.lib.test.ts b/src/tools/calculators/tdee.lib.test.ts new file mode 100644 index 0000000..d90b46a --- /dev/null +++ b/src/tools/calculators/tdee.lib.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { bmr, tdee, calorieGoals } from './tdee.lib'; + +describe('bmr', () => { + it('male: 10w + 6.25h − 5a + 5', () => { + // 80kg, 180cm, 30y → 800 + 1125 − 150 + 5 = 1780 + expect(bmr('male', 80, 180, 30)).toBe(1780); + }); + it('female: same base − 161', () => { + expect(bmr('female', 80, 180, 30)).toBe(1614); + }); +}); + +describe('tdee', () => { + it('scales BMR by the activity factor', () => { + expect(tdee('male', 80, 180, 30, 'moderate')).toBeCloseTo(2759, 0); + expect(tdee('male', 80, 180, 30, 'sedentary')).toBeCloseTo(2136, 0); + }); +}); + +describe('calorieGoals', () => { + it('offsets ±250/±500 around maintenance', () => { + const g = calorieGoals(2000); + expect(g).toEqual({ loseFast: 1500, lose: 1750, maintain: 2000, gain: 2250, gainFast: 2500 }); + }); +}); diff --git a/src/tools/calculators/tdee.lib.ts b/src/tools/calculators/tdee.lib.ts new file mode 100644 index 0000000..046ea22 --- /dev/null +++ b/src/tools/calculators/tdee.lib.ts @@ -0,0 +1,36 @@ +/** Pure BMR/TDEE math using the Mifflin-St Jeor equation. */ + +export type Sex = 'male' | 'female'; +export type Activity = 'sedentary' | 'light' | 'moderate' | 'active' | 'veryActive'; + +export const ACTIVITY_FACTORS: Record = { + sedentary: 1.2, + light: 1.375, + moderate: 1.55, + active: 1.725, + veryActive: 1.9, +}; + +/** Basal Metabolic Rate (kcal/day), Mifflin-St Jeor. */ +export function bmr(sex: Sex, kg: number, cm: number, age: number): number { + const base = 10 * kg + 6.25 * cm - 5 * age; + return sex === 'male' ? base + 5 : base - 161; +} + +/** Total Daily Energy Expenditure (kcal/day) = BMR × activity factor. */ +export function tdee(sex: Sex, kg: number, cm: number, age: number, activity: Activity): number { + return bmr(sex, kg, cm, age) * ACTIVITY_FACTORS[activity]; +} + +/** Calorie targets around maintenance for common goals. */ +export function calorieGoals(tdeeValue: number): { + loseFast: number; lose: number; maintain: number; gain: number; gainFast: number; +} { + return { + loseFast: tdeeValue - 500, + lose: tdeeValue - 250, + maintain: tdeeValue, + gain: tdeeValue + 250, + gainFast: tdeeValue + 500, + }; +}