Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/islands/calculators/BmiCalculator.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, Record<string, string>> = {
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<string, string> = {
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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="flex gap-1">
{(['metric', 'imperial'] as const).map(u => (
<button key={u} onClick={() => setUnit(u)} aria-pressed={unit === u}
className={`border-2 px-4 py-1.5 text-sm font-bold uppercase tracking-wide ${unit === u ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border'}`}>
{t[u]}
</button>
))}
</div>

{unit === 'metric' ? (
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.height} ({t.cm})</span>
<input value={cm} onChange={e => setCm(e.target.value)} inputMode="decimal" className={input} /></label>
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.weight} ({t.kg})</span>
<input value={kg} onChange={e => setKg(e.target.value)} inputMode="decimal" className={input} /></label>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.height} ({t.ft})</span>
<input value={ft} onChange={e => setFt(e.target.value)} inputMode="numeric" className={input} /></label>
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.height} ({t.in})</span>
<input value={inch} onChange={e => setInch(e.target.value)} inputMode="numeric" className={input} /></label>
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.weight} ({t.lb})</span>
<input value={lb} onChange={e => setLb(e.target.value)} inputMode="decimal" className={input} /></label>
</div>
)}

{bmi > 0 && (
<div className={`border-2 border-border p-4 text-black shadow-brutal dark:text-white ${CAT_COLOR[cat]}`}>
<div className="text-xs font-bold uppercase tracking-wide">{t.result}</div>
<div className="text-4xl font-black tabular-nums">{bmi.toFixed(1)}</div>
<div className="mt-1 font-bold uppercase">{t[cat]}</div>
<div className="mt-2 text-xs opacity-80">{t.healthy}: {kgToUnit(range.min)} – {kgToUnit(range.max)}</div>
</div>
)}
</div>
);
}
87 changes: 87 additions & 0 deletions src/islands/calculators/DateDuration.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, Record<string, string>> = {
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 (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<section className="space-y-3">
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.diff}</h2>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.from}</span>
<input type="date" value={from} onChange={e => setFrom(e.target.value)} className={input} /></label>
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.to}</span>
<input type="date" value={to} onChange={e => setTo(e.target.value)} className={input} /></label>
</div>
{valid && (
<div className="grid gap-3 sm:grid-cols-3">
<div className="border-2 border-border bg-lime-200 p-4 text-black shadow-brutal dark:bg-lime-900/40 dark:text-white">
<div className="text-xs font-bold uppercase tracking-wide">{t.breakdown}</div>
<div className="text-2xl font-black tabular-nums">{ymd.years}y {ymd.months}m {ymd.days}d</div>
<div className="text-xs opacity-80">{ymd.years} {t.y}, {ymd.months} {t.mo}, {ymd.days} {t.d}</div>
</div>
<div className="border-2 border-border bg-muted p-4 shadow-brutal">
<div className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.totalDays}</div>
<div className="text-2xl font-black tabular-nums">{Math.abs(total).toLocaleString()}</div>
</div>
<div className="border-2 border-border bg-muted p-4 shadow-brutal">
<div className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.workDays}</div>
<div className="text-2xl font-black tabular-nums">{work.toLocaleString()}</div>
</div>
</div>
)}
</section>

<section className="space-y-3 border-t-2 border-border pt-5">
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.addSub}</h2>
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.startDate}</span>
<input type="date" value={start} onChange={e => setStart(e.target.value)} className={input} /></label>
<label className="space-y-1 text-sm"><span className="block font-semibold">{t.offset}</span>
<input value={offset} onChange={e => setOffset(e.target.value)} inputMode="numeric" className={input} /></label>
<div className="space-y-1 text-sm">
<span className="block font-semibold">{t.resultDate}</span>
<div className="border-2 border-border bg-lime-200 p-2 font-mono text-lg font-black tabular-nums text-black shadow-brutal-sm dark:bg-lime-900/40 dark:text-white">{resultDate}</div>
</div>
</div>
</section>
</div>
);
}
75 changes: 75 additions & 0 deletions src/islands/calculators/GpaCalculator.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, Record<string, string>> = {
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<Row[]>([
{ id: 1, name: '', grade: 'A', credits: 3 },
{ id: 2, name: '', grade: 'B+', credits: 4 },
]);
const nextId = useRef(3);

const update = (id: number, patch: Partial<Row>) =>
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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="space-y-2">
<div className="hidden gap-2 px-1 text-xs font-bold uppercase tracking-wide text-muted-foreground sm:grid sm:grid-cols-[1fr_120px_100px_40px]">
<span>{t.course}</span><span>{t.grade}</span><span>{t.credits}</span><span />
</div>
{rows.map(r => (
<div key={r.id} className="grid grid-cols-[1fr_90px_70px_40px] gap-2 sm:grid-cols-[1fr_120px_100px_40px]">
<input value={r.name} onChange={e => update(r.id, { name: e.target.value })} placeholder={t.coursePh} className={input} />
<select value={r.grade} onChange={e => update(r.id, { grade: e.target.value })} className={input}>
{GRADES.map(g => <option key={g} value={g}>{g}</option>)}
</select>
<input value={String(r.credits)} onChange={e => update(r.id, { credits: Number(e.target.value) || 0 })} inputMode="decimal" className={input} />
<button onClick={() => removeRow(r.id)} aria-label="remove" className="flex items-center justify-center text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
))}
<Button variant="secondary" onClick={addRow}><Plus className="h-4 w-4" />{t.add}</Button>
</div>

<div className="grid gap-3 sm:grid-cols-2">
<div className="border-2 border-border bg-lime-200 p-4 text-black shadow-brutal dark:bg-lime-900/40 dark:text-white">
<div className="text-xs font-bold uppercase tracking-wide">{t.gpa}</div>
<div className="text-4xl font-black tabular-nums">{gpa.toFixed(2)}</div>
</div>
<div className="border-2 border-border bg-muted p-4 shadow-brutal">
<div className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.totalCredits}</div>
<div className="text-4xl font-black tabular-nums">{credits}</div>
</div>
</div>
</div>
);
}
92 changes: 92 additions & 0 deletions src/islands/calculators/ScientificCalc.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, Record<string, string>> = {
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<string | null>(null);
const [angle, setAngle] = useState<AngleMode>('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 (
<div className="max-w-md space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="flex justify-between">
<div className="flex gap-1">
{(['deg', 'rad'] as const).map(a => (
<button key={a} onClick={() => setAngle(a)} aria-pressed={angle === a}
className={`border-2 px-3 py-1 text-xs font-bold uppercase tracking-wide ${angle === a ? 'border-border bg-accent text-accent-foreground shadow-brutal-sm' : 'border-border'}`}>
{a}
</button>
))}
</div>
</div>

<div className="border-2 border-border bg-muted p-3 shadow-brutal">
<input
value={expr}
onChange={e => { 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"
/>
<div className="mt-1 text-right font-mono text-3xl font-black tabular-nums text-foreground">
{result ?? ' '}
</div>
</div>

<div className="grid grid-cols-5 gap-2">
{KEYS.flat().map((k, i) => (
<button
key={i}
onClick={() => press(k)}
className={`border-2 border-border py-3 text-lg font-bold press-brutal ${
k.act === 'eq' ? 'bg-accent text-accent-foreground shadow-brutal'
: k.act === 'clear' || k.act === 'back' ? 'bg-rose-200 text-black dark:bg-rose-900/40 dark:text-white'
: /^[0-9.]$/.test(k.label) ? 'bg-background' : 'bg-muted'
}`}
>
{k.label}
</button>
))}
</div>
</div>
);
}
Loading
Loading