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
34 changes: 34 additions & 0 deletions docs/superpowers/plans/2026-08-14-nik-decoder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# NIK / KTP Decoder — Spec + Plan

**Date:** 2026-08-14 · Category: Dev · id: `nik-decoder`

## Goal
Validate an Indonesian **NIK** (16-digit Nomor Induk Kependudukan / KTP number) and break out its structure — province, regency & district codes, birth date, gender, and serial — entirely client-side. Zero uploads.

## NIK structure (16 digits)
`PP KK CC | DD MM YY | SSSS`
- `PP` province code (2) → mapped to province name (38-province table, incl. 2022 additions).
- `KK` regency/city code (2), `CC` district/kecamatan code (2) — shown as codes (full name mapping needs the ~80k-row Kemendagri dataset, out of scope; codes + province name are the useful, embeddable part).
- `DD MM YY` birth date. **Female → day + 40** (so `DD` 41–71 means female, real day = DD−40).
- `SSSS` computer-generated serial (0001–9999).
- No checksum digit → validation is structural (length, digits, known province, valid month/day).

## Architecture
### Pure lib `src/tools/dev/nik.lib.ts`
- `PROVINCES: Record<string,string>` (province code → name).
- `parseNik(nik: string, currentYear: number): NikResult` — pure, `currentYear` passed in (island passes `new Date().getFullYear()`) so the century heuristic stays deterministic/testable.
- Century: `fullYear = 2000+yy <= currentYear ? 2000+yy : 1900+yy`.
- `NikResult = { valid: boolean; issues: string[]; provinceCode; province; regencyCode; districtCode; gender: 'male'|'female'; birthDate: { day; month; year } | null; birthDateISO: string | null; serial; }`.
- Validates: 16 digits; province in table (else issue, still decodes); month 1–12; real day 1–31.

### Island `src/islands/dev/NikDecoder.tsx`
Input (controlled, digit-filtered) → `useMemo(parseNik(value, new Date().getFullYear()))` → validity badge + labeled rows (province, regency code, district code, gender, birth date, age, serial) + `CopyButton`. Example button. i18n TR en+id. SSR-safe.

### Registry + SEO
`tools.ts` ToolDef (icon `SquareUser`). EN + ID `nik-decoder` SEO (title/description/intro/howTo/faqs). Keywords: "cek NIK", "decode NIK KTP", "NIK validator", "arti NIK". Bahasa "tool" loanword. **Privacy note in copy/UI:** decoding is local; nothing is uploaded (important for PII).

## Testing
`nik.lib.test.ts`: valid male NIK → fields; female (day+40) → gender female + corrected day; province lookup; century heuristic (pass fixed currentYear); invalids (length, non-digit, month 13, day 00) → issues; unknown province flagged.

## Definition of done
Spec+plan committed · lib unit-tested · EN+ID SEO w/ howTo · vitest+lint+build green · `/tools/nik-decoder` + `/id/…` built · develop → main → live verified · PWA hard-refresh note.
138 changes: 138 additions & 0 deletions src/islands/dev/NikDecoder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { CopyButton } from '@/components/ui/CopyButton';
import { parseNik } from '@/tools/dev/nik.lib';
import type { Lang } from '@/i18n/config';

const MONTHS: Record<Lang, string[]> = {
en: ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
id: ['', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'],
};

const TR: Record<Lang, {
intro: string;
label: string;
valid: string;
invalid: string;
labels: { province: string; regency: string; district: string; gender: string; birthDate: string; age: string; serial: string };
male: string;
female: string;
years: string;
example: string;
privacy: string;
}> = {
en: {
intro: 'Validate an Indonesian NIK (the 16-digit KTP number) and break out the province, birth date, gender and serial. It runs entirely in your browser — the NIK is never uploaded.',
label: 'NIK (16 digits)',
valid: 'Valid structure',
invalid: 'Invalid',
labels: { province: 'Province', regency: 'Regency/city code', district: 'District code', gender: 'Gender', birthDate: 'Birth date', age: 'Age', serial: 'Serial' },
male: 'Male', female: 'Female', years: 'years',
example: 'Load example',
privacy: 'A NIK is personal data — this tool decodes it locally and never sends it anywhere.',
},
id: {
intro: 'Validasi NIK Indonesia (nomor KTP 16 digit) dan uraikan provinsi, tanggal lahir, jenis kelamin, dan serial. Berjalan sepenuhnya di browser Anda — NIK tidak pernah diunggah.',
label: 'NIK (16 digit)',
valid: 'Struktur valid',
invalid: 'Tidak valid',
labels: { province: 'Provinsi', regency: 'Kode kabupaten/kota', district: 'Kode kecamatan', gender: 'Jenis kelamin', birthDate: 'Tanggal lahir', age: 'Usia', serial: 'Serial' },
male: 'Laki-laki', female: 'Perempuan', years: 'tahun',
example: 'Muat contoh',
privacy: 'NIK adalah data pribadi — tool ini men-decode secara lokal dan tidak pernah mengirimnya ke mana pun.',
},
};

function ageFrom(iso: string): number {
const b = new Date(iso + 'T00:00:00');
const now = new Date();
let age = now.getFullYear() - b.getFullYear();
const m = now.getMonth() - b.getMonth();
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
return age;
}

export default function NikDecoder({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [value, setValue] = useState('');

const result = useMemo(() => (value.trim() ? parseNik(value, new Date().getFullYear()) : null), [value]);

const rows = result
? [
{ label: t.labels.province, value: result.province ? `${result.province} (${result.provinceCode})` : `— (${result.provinceCode})` },
{ label: t.labels.regency, value: result.regencyCode },
{ label: t.labels.district, value: result.districtCode },
{ label: t.labels.gender, value: result.gender === 'female' ? t.female : t.male },
{
label: t.labels.birthDate,
value: result.birthDate
? `${result.birthDate.day} ${MONTHS[lang][result.birthDate.month]} ${result.birthDate.year}`
: '—',
},
{ label: t.labels.age, value: result.birthDateISO ? `${ageFrom(result.birthDateISO)} ${t.years}` : '—' },
{ label: t.labels.serial, value: result.serial },
]
: [];

const copyText = result ? rows.map(r => `${r.label}: ${r.value}`).join('\n') : '';

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{t.label}</span>
<button type="button" onClick={() => setValue('3175711708900001')} className="text-sm text-accent underline">
{t.example}
</button>
</div>
<input
value={value}
onChange={e => setValue(e.target.value.replace(/[^\d\s.-]/g, '').slice(0, 20))}
inputMode="numeric"
spellCheck={false}
className="w-full border-2 border-border bg-muted p-3 font-mono text-lg tracking-wider"
placeholder="3201234567890001"
/>
</div>

<p className="text-xs text-muted-foreground">{t.privacy}</p>

{result && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className={`border-2 px-2 py-0.5 text-xs font-bold ${
result.valid
? 'border-green-600 text-green-700 dark:border-green-400 dark:text-green-400'
: 'border-red-500 text-red-600 dark:text-red-400'
}`}>
{result.valid ? t.valid : t.invalid}
</span>
<CopyButton value={copyText} />
</div>

{result.issues.length > 0 && (
<Alert variant="error">
<ul className="list-inside list-disc">
{result.issues.map((iss, i) => <li key={i}>{iss}</li>)}
</ul>
</Alert>
)}

{result.birthDate && (
<div className="divide-y divide-border border-2 border-border">
{rows.map(r => (
<div key={r.label} className="flex flex-wrap items-baseline gap-x-3 px-3 py-2 text-sm">
<span className="w-44 shrink-0 font-medium text-muted-foreground">{r.label}</span>
<span className="break-all font-mono">{r.value}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
124 changes: 124 additions & 0 deletions src/islands/dev/TaxCalculator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useMemo, useState } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { computePpn, computePph, PPN_RATES, PPH_PRESETS } from '@/tools/dev/tax.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string;
amount: string;
ppnRate: string;
inclusive: string;
pph: string;
none: string;
dpp: string;
ppn: string;
invoiceTotal: string;
pphWithheld: string;
netToVendor: string;
}> = {
en: {
intro: 'Calculate PPN (VAT) and PPh (withholding) on an invoice amount. Everything is computed in your browser.',
amount: 'Amount (Rp)',
ppnRate: 'PPN rate',
inclusive: 'Amount already includes PPN',
pph: 'PPh withholding',
none: 'None',
dpp: 'Base (DPP)',
ppn: 'PPN',
invoiceTotal: 'Invoice total (DPP + PPN)',
pphWithheld: 'PPh withheld',
netToVendor: 'Net paid to vendor',
},
id: {
intro: 'Hitung PPN dan PPh (potongan) pada nilai invoice. Semua dihitung di browser Anda.',
amount: 'Nominal (Rp)',
ppnRate: 'Tarif PPN',
inclusive: 'Nominal sudah termasuk PPN',
pph: 'Potongan PPh',
none: 'Tidak ada',
dpp: 'Dasar (DPP)',
ppn: 'PPN',
invoiceTotal: 'Total invoice (DPP + PPN)',
pphWithheld: 'PPh dipotong',
netToVendor: 'Diterima vendor',
},
};

const rp = (n: number) => 'Rp ' + new Intl.NumberFormat('id-ID').format(n);

export default function TaxCalculator({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [amount, setAmount] = useState('1000000');
const [ppnRate, setPpnRate] = useState(11);
const [inclusive, setInclusive] = useState(false);
const [pphRate, setPphRate] = useState(0);

const result = useMemo(() => {
const value = Number(amount.replace(/[.,\s]/g, ''));
if (!Number.isFinite(value) || value <= 0) return null;
const ppn = computePpn(value, ppnRate / 100, inclusive);
const pph = pphRate > 0 ? computePph(ppn.dpp, pphRate) : { pph: 0, net: ppn.dpp };
return { ...ppn, pphAmount: pph.pph, net: ppn.total - pph.pph };
}, [amount, ppnRate, inclusive, pphRate]);

const rows = result
? [
{ label: t.dpp, value: rp(result.dpp) },
{ label: t.ppn, value: rp(result.ppn) },
{ label: t.invoiceTotal, value: rp(result.total), strong: true },
...(pphRate > 0 ? [{ label: t.pphWithheld, value: '− ' + rp(result.pphAmount) }] : []),
...(pphRate > 0 ? [{ label: t.netToVendor, value: rp(result.net), strong: true }] : []),
]
: [];

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<label className="block space-y-1">
<span className="block text-sm font-semibold">{t.amount}</span>
<input value={amount} onChange={e => setAmount(e.target.value)} inputMode="numeric" spellCheck={false}
className="w-full border-2 border-border bg-muted p-3 font-mono text-lg" placeholder="1000000" />
</label>

<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
<label className="space-y-1 text-sm">
<span className="block font-semibold">{t.ppnRate}</span>
<select value={ppnRate} onChange={e => setPpnRate(Number(e.target.value))}
className="border-2 border-border bg-background px-2 py-1.5 text-sm">
{PPN_RATES.map(r => <option key={r} value={r}>{r}%</option>)}
</select>
</label>
<label className="flex items-center gap-2 text-sm font-semibold">
<input type="checkbox" checked={inclusive} onChange={e => setInclusive(e.target.checked)} className="h-4 w-4 accent-accent" />
{t.inclusive}
</label>
</div>

<label className="block space-y-1 text-sm">
<span className="block font-semibold">{t.pph}</span>
<select value={pphRate} onChange={e => setPphRate(Number(e.target.value))}
className="w-full max-w-md border-2 border-border bg-background px-2 py-1.5 text-sm">
<option value={0}>{t.none}</option>
{PPH_PRESETS.map(p => <option key={p.label} value={p.rate}>{p.label}</option>)}
</select>
</label>

{result && (
<div className="space-y-2">
<div className="flex justify-end">
<CopyButton value={rows.map(r => `${r.label}: ${r.value}`).join('\n')} />
</div>
<div className="divide-y divide-border border-2 border-border">
{rows.map(r => (
<div key={r.label} className="flex flex-wrap items-baseline justify-between gap-x-3 px-3 py-2 text-sm">
<span className="font-medium text-muted-foreground">{r.label}</span>
<span className={`font-mono ${r.strong ? 'text-base font-bold' : ''}`}>{r.value}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
68 changes: 68 additions & 0 deletions src/islands/dev/Terbilang.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useMemo, useState } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { terbilang, terbilangRupiah, capitalize } from '@/tools/dev/terbilang.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, { intro: string; label: string; words: string; rupiah: string; placeholder: string }> = {
en: {
intro: 'Convert a number into Indonesian words (terbilang) — handy for invoices, cheques and kwitansi. Runs entirely in your browser.',
label: 'Number',
words: 'In words',
rupiah: 'As rupiah',
placeholder: 'e.g. 1500000',
},
id: {
intro: 'Ubah angka menjadi terbilang (kata-kata Bahasa Indonesia) — berguna untuk invoice, cek, dan kwitansi. Berjalan sepenuhnya di browser Anda.',
label: 'Angka',
words: 'Terbilang',
rupiah: 'Dalam rupiah',
placeholder: 'mis. 1500000',
},
};

export default function Terbilang({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [value, setValue] = useState('');

const num = useMemo(() => {
const cleaned = value.replace(/[.,\s]/g, '');
return /^-?\d+$/.test(cleaned) ? Number(cleaned) : null;
}, [value]);

const words = num !== null ? capitalize(terbilang(num)) : '';
const rupiah = num !== null ? capitalize(terbilangRupiah(num)) : '';
const grouped = num !== null ? new Intl.NumberFormat('id-ID').format(num) : '';

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<label className="block space-y-1">
<span className="block text-sm font-semibold">{t.label}</span>
<input
value={value}
onChange={e => setValue(e.target.value)}
inputMode="numeric"
spellCheck={false}
className="w-full border-2 border-border bg-muted p-3 font-mono text-lg"
placeholder={t.placeholder}
/>
{grouped && <span className="text-xs text-muted-foreground">{grouped}</span>}
</label>

{num !== null && (
<div className="space-y-3">
{[{ label: t.words, text: words }, { label: t.rupiah, text: rupiah }].map(o => (
<div key={o.label} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{o.label}</span>
<CopyButton value={o.text} />
</div>
<div className="border-2 border-border bg-muted p-3 text-lg">{o.text}</div>
</div>
))}
</div>
)}
</div>
);
}
Loading
Loading