State pada Bagian 2 sekarang menampung angka yang jujur bagi program tetapi berat bagi mata: 3420 pengunjung dan 34500000 pendapatan harus dibaca digit demi digit, dan perbandingan terhadap sasaran hanya bisa dihitung di kepala. Dashboard yang baik melakukan terjemahan ini di depan: 3420 menjadi 3,4 rb, rasio konversi menjadi 3,7%, dan posisi pencapaian menjadi bar yang bisa dibaca sekilas. Terjemahan itu tidak boleh ditulis begitu saja di dalam skrip antarmuka — semuanya fungsi murni yang menerima angka dan mengembalikan teks, sehingga setiap format bisa dikunci dengan uji di terminal, sama seperti fungsi state sebelumnya.
Bagian ini menambah blok formatter di dashboard.mjs — ringkasAngka, formatPersen, dan buatBar — lalu merakit antarmuka lengkapnya: tiga kartu ringkas dengan bar, baris konversi, tabel transaksi, dan daftar aktivitas. Prinsipnya tetap sama: logika menghitung dan memformat, antarmuka hanya menempelkan hasilnya ke DOM.
Meringkas Angka dengan Intl #
Penerjemah angka dibangun di atas Intl.NumberFormat — API standar JavaScript untuk pemformatan angka menurut locale. Perhatikan bentuknya di dashboard.mjs:
const RINGKAS = new Intl.NumberFormat('id-ID', {
notation: 'compact',
maximumFractionDigits: 1,
});
export function ringkasAngka(nilai) {
// ICU menyelipkan spasi non-breaking (U+00A0) sebelum satuan; kita rapikan
// menjadi spasi biasa agar tampilan aman di font mana pun.
return RINGKAS.format(nilai).replace(/\u00a0/g, ' ');
}Mari kita bedah:
- Baris 37 (
const RINGKAS = new Intl.NumberFormat('id-ID', {): instance formatter dibuat satu kali di luar fungsi. MenyusunIntl.NumberFormatadalah pekerjaan yang berbiaya — locale, aturan notasi, dan opsi harus diproses — sedangkan memanggil.format()di atas instance yang sudah jadi murah. Karena halaman ini memformat banyak angka dengan aturan yang sama, satu instance dipakai ulang untuk semuanya. - Baris 38 (
notation: 'compact',): notasi ringkas gaya Indonesia — ribuan menjadirb, jutaan menjadijt. Angka yang sama akan berbeda di locale lain: formatter yang dibuat dengan localeen-USmenghasilkan3.4K. - Baris 39 (
maximumFractionDigits: 1,): paling banyak satu angka di belakang koma —3420menjadi3,4 rb, bukan3,42 rb. - Baris 45 (
return RINGKAS.format(nilai).replace(/\u00a0/g, ' ');):format()mengembalikan teks yang siap tampil, tetapi ICU — pustaka data locale yang dipakai JavaScript — menyisipkan spasi non-breaking (U+00A0) sebelum satuan, bukan spasi biasa. Mata sulit membedakannya, tetapi perilakunya berbeda saat disalin atau diukur;replacemengubahnya menjadi spasi biasa agar tampilan aman di font mana pun.
Demo berikut memanggil ketiga formatter langsung dari terminal:
// demo-formatter.mjs — formatter murni dipanggil langsung dari terminal.
import { ringkasAngka, formatPersen, buatBar } from './dashboard.mjs';
console.log('Pengunjung :', ringkasAngka(3420));
console.log('Pendapatan :', ringkasAngka(34500000));
console.log('Konversi :', formatPersen(128 / 3420));
console.log('Bar 68% :', buatBar(3420, 5000, 10));
console.log('Bar penuh :', buatBar(150, 150, 10));node demo-formatter.mjsOutput yang Dihasilkan:
Pengunjung : 3,4 rb
Pendapatan : 34,5 jt
Konversi : 3,7%
Bar 68% : ███████░░░
Bar penuh : ██████████Keluaran itu merangkum tiga aturan sekaligus: 3420 menjadi 3,4 rb, 34500000 menjadi 34,5 jt, dan rasio 128 / 3420 menjadi 3,7% — koma desimal gaya Indonesia, bukan titik.
Persen dan Bar Teks #
Dua formatter lain menutup blok visualisasi. formatPersen memakai style percent:
export function formatPersen(rasio, digit = 1) {
return new Intl.NumberFormat('id-ID', {
style: 'percent',
maximumFractionDigits: digit,
}).format(rasio);
}Mari kita bedah:
- Baris 48 (
export function formatPersen(rasio, digit = 1) {): fungsi menerima rasio — pecahan antara 0 dan 1 — karena itulah bentuk yang diharapkan stylepercent:0.0374ditampilkan sebagai3,7%. Parameterdigitmenentukan jumlah desimal dengan bawaan 1, sehingga pemanggil bisa memintaformatPersen(rasio, 0)tanpa formatter kedua. - Baris 49–52 (
return new Intl.NumberFormat('id-ID', { style: 'percent', ... }).format(rasio);): berbeda dariringkasAngka, formatter persen disusun di dalam fungsi. Jumlah pemanggilannya kecil di halaman ini — satu baris konversi — sehingga biayanya tak terlihat; bila kelak menjadi titik panas, pola instance di luar fungsi sepertiRINGKAStinggal ditiru.
Bar pencapaian dibuat dari teks, bukan gambar:
export function buatBar(nilai, maks, lebar = 10) {
const terisi = Math.round((nilai / maks) * lebar);
return '█'.repeat(terisi) + '░'.repeat(lebar - terisi);
}Mari kita bedah:
- Baris 56 (
const terisi = Math.round((nilai / maks) * lebar);): perbandingan nilai terhadap maksimum dikalikan lebar petak. Untuk3420dari sasaran5000dengan lebar 10 petak:3420 / 5000 * 10 = 6,84, danMath.roundmembulatkannya menjadi 7 petak terisi. - Baris 57 (
return '█'.repeat(terisi) + '░'.repeat(lebar - terisi);): petak penuh direpeat sebanyakterisi, petak kosong░mengisi sisanya — hasilnya███████░░░. Keunggulan bar teks: ia tampil sama di terminal dan di DOM, tanpa satu pun pustaka grafik, dan bisa diklaim uji sebagai string biasa.
Antarmuka: Satu State, Satu Render #
Dengan state dan formatter siap, antarmuka lengkapnya dirakit. index.html.txt berikut adalah wujud final antarmuka proyek ini — berkas yang sama tersimpan di src/:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard Data</title>
<link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
<main class="dashboard">
<header class="dashboard-atas">
<h1>Dashboard Toko</h1>
<button id="tombol-muat" class="tombol-muat">Muat Ulang</button>
</header>
<p id="status" class="status" hidden></p>
<ul id="daftar-gagal" class="daftar-gagal"></ul>
<section id="kartu" class="kartu" hidden></section>
<p id="konversi" class="konversi" hidden></p>
<section id="panel" class="panel" hidden>
<article class="panel-item">
<h2>Transaksi Terbaru</h2>
<table class="tabel-transaksi">
<thead>
<tr><th>ID</th><th>Pembeli</th><th>Total</th><th>Status</th></tr>
</thead>
<tbody></tbody>
</table>
</article>
<article class="panel-item">
<h2>Aktivitas Terakhir</h2>
<ul id="daftar-aktivitas" class="daftar-aktivitas"></ul>
</article>
</section>
</main>
<script type="module">
import {
buatState,
muatDashboard,
setState,
terapkanData,
ringkasAngka,
formatPersen,
buatBar,
} from './dashboard.mjs';
// Pemuat tiruan; di aplikasi nyata diganti fetch ke API internal Anda.
const PEMUAT = {
statistik: () =>
Promise.resolve({ pengunjung: 3420, pesanan: 128, pendapatan: 34500000 }),
transaksi: () =>
Promise.resolve([
{ id: 'TRX-1042', pembeli: 'Budi Santoso', total: 1250000, status: 'lunas' },
{ id: 'TRX-1041', pembeli: 'Siti Rahma', total: 890000, status: 'lunas' },
{ id: 'TRX-1040', pembeli: 'Andi Wirawan', total: 2100000, status: 'menunggu' },
]),
aktivitas: () =>
Promise.resolve([
{ waktu: '09:12', teks: 'Pesanan TRX-1042 dibayar' },
{ waktu: '08:47', teks: 'Stok "Kopi Arabika 250g" diperbarui' },
{ waktu: '08:30', teks: 'Pesanan TRX-1040 dibuat' },
]),
};
const KARTU = [
{ kunci: 'pengunjung', label: 'Pengunjung', sasaran: 5000 },
{ kunci: 'pesanan', label: 'Pesanan', sasaran: 150 },
{ kunci: 'pendapatan', label: 'Pendapatan', sasaran: 40000000 },
];
const PESAN_KEADAAN = { memuat: 'Memuat data…', galat: 'Data gagal dimuat.' };
const elemenStatus = document.querySelector('#status');
const elemenGagal = document.querySelector('#daftar-gagal');
const elemenKartu = document.querySelector('#kartu');
const elemenKonversi = document.querySelector('#konversi');
const elemenPanel = document.querySelector('#panel');
const elemenAktivitas = document.querySelector('#daftar-aktivitas');
let state = buatState();
function render() {
elemenStatus.textContent = PESAN_KEADAAN[state.keadaan] ?? '';
elemenStatus.hidden = state.keadaan === 'siap' || state.keadaan === 'sebagian';
// Field gagal diisi mulai Bagian 4; sebelum itu daftarnya selalu kosong.
const gagal = state.gagal ?? [];
elemenGagal.replaceChildren(
...gagal.map((item) => {
const baris = document.createElement('li');
baris.textContent = `${item.sumber}: ${item.message}`;
return baris;
}),
);
const siapData = state.statistik !== null;
elemenKartu.hidden = !siapData;
elemenKonversi.hidden = !siapData;
elemenPanel.hidden = !siapData;
if (!siapData) return;
const fragmen = document.createDocumentFragment();
for (const definisi of KARTU) {
const nilai = state.statistik[definisi.kunci];
const kartu = document.createElement('article');
kartu.className = 'kartu-item';
const judul = document.createElement('h2');
judul.textContent = definisi.label;
const angka = document.createElement('p');
angka.className = 'kartu-angka';
angka.textContent = ringkasAngka(nilai);
const bar = document.createElement('p');
bar.className = 'kartu-bar';
bar.textContent = buatBar(nilai, definisi.sasaran, 10);
kartu.append(judul, angka, bar);
fragmen.append(kartu);
}
elemenKartu.replaceChildren(fragmen);
elemenKonversi.textContent = `Konversi: ${formatPersen(
state.statistik.pesanan / state.statistik.pengunjung,
)}`;
const badan = elemenPanel.querySelector('tbody');
badan.replaceChildren(
...state.transaksi.map((trx) => {
const baris = document.createElement('tr');
const selId = document.createElement('td');
selId.textContent = trx.id;
const selPembeli = document.createElement('td');
selPembeli.textContent = trx.pembeli;
const selTotal = document.createElement('td');
selTotal.textContent = `Rp${ringkasAngka(trx.total)}`;
const selStatus = document.createElement('td');
selStatus.textContent = trx.status;
baris.append(selId, selPembeli, selTotal, selStatus);
return baris;
}),
);
elemenAktivitas.replaceChildren(
...state.aktivitas.map((aksi) => {
const baris = document.createElement('li');
baris.textContent = `${aksi.waktu} · ${aksi.teks}`;
return baris;
}),
);
}
async function muatUlang() {
state = buatState();
render();
try {
const data = await muatDashboard(PEMUAT);
state = terapkanData(state, data);
} catch (galat) {
state = setState(state, {
statistik: null,
transaksi: [],
aktivitas: [],
keadaan: 'galat',
});
}
render();
}
document.querySelector('#tombol-muat').addEventListener('click', muatUlang);
muatUlang();
</script>
</body>
</html>Skrip antarmukanya tetap tipis — semua keputusan data sudah diimpor dari dashboard.mjs. Mari kita bedah bagian penentu:
- Baris 38 (
<script type="module">): module ES di browser, dengan impor dari'./dashboard.mjs'pada Baris 39–47. Logika tidak disalin-tempel ke HTML; antarmuka mengimpor fungsi yang sama yang diuji di terminal. - Baris 50 (
const PEMUAT = {): pemuat tiruan di antarmuka — tiga fungsi yang mengembalikanPromise.resolve(...)berisi data contoh. Komentar pada berkas mengingatkan: di aplikasi nyata pemuat ini digantifetchke API internal. Course ini tidak memanggil internet mana pun, sehingga halaman contoh tetap hidup tanpa koneksi. - Baris 67 (
const KARTU = [): definisi tiga kartu berupa data —kuncifield statistik,labelyang tampil, dansasaranuntuk bar. Menambah kartu berarti menambah satu baris konfigurasi, bukan menyalin blok render. - Baris 82 (
let state = buatState();): satu-satunya variabel state di skrip ini. Seluruh tampilan adalah fungsi dari state ini, persis aturan Bagian 2. - Baris 85–86 (
elemenStatus.textContent = PESAN_KEADAAN[state.keadaan] ?? ''; ...): teks status dipetakan dari keadaan —'memuat'menampilkan “Memuat data…”,'galat'menampilkan “Data gagal dimuat.”;?? ''menampung keadaan tanpa pesan. Baris kedua menyembunyikan status ketika keadaan'siap'maupun'sebagian'— keadaan yang baru lahir di Bagian 4 sudah diperlakukan di sini. - Baris 89 (
const gagal = state.gagal ?? [];): slot laporan kegagalan. Selama logika Bagian 1–2 belum menghasilkan fieldgagal, ekspresi ini menghasilkan daftar kosong dan panelnya tersembunyi; isinya menyusul saat logika Bagian 4 melaporkan kegagalan per sumber. - Baris 104 (
const fragmen = document.createDocumentFragment();): tiga kartu dibangun di dalam fragment lalu ditempel sekali lewatreplaceChildren(fragmen)pada Baris 125 — pola render batch yang sama dengan proyek pencarian dan filter, satu penempelan tanpa flicker bertahap. - Baris 120 (
bar.textContent = buatBar(nilai, definisi.sasaran, 10);): bar teks dirender sebagai teks biasa; CSS cukup memberi font monospace dan jarak huruf. - **Baris 127–129 (
elemenKonversi.textContent = \Konversi: ${formatPersen(…)}`;)**: rasio konversi dihitung dari statistik yang ada —pesanan / pengunjung— lalu diformatformatPersen`. - Baris 131–151 (
const badan = elemenPanel.querySelector('tbody'); ...): tabel transaksi dibangun baris demi baris —trdengan empattd; total memakaiRpdiikutiringkasAngka, sehingga1250000tampil sebagaiRp1,3 jt. - Baris 162–164 (
async function muatUlang() { state = buatState(); render(); ...): pemuatan ulang dimulai dari state awal dan render pertama — pengguna melihat status memuat sebelum permintaan selesai, bukan sesudahnya. - Baris 165–176 (
try { ... } catch (galat) { ... } render();): jalur sukses memakaimuatDashboardlaluterapkanData; jalur gagal mengganti seluruh state dengan wujud'galat'lewatsetState. Kedua jalur bertemu dirender()yang sama pada Baris 176, sehingga tidak ada keadaan yang terlupa digambar. - Baris 179–180 (
document.querySelector('#tombol-muat').addEventListener('click', muatUlang); muatUlang();): tombol memasang listener, dan pemuatan pertama dijalankan begitu halaman dibuka.
CSS-nya menemani dengan keputusan kecil yang berarti:
/* styles.css — tampilan dashboard data */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px 16px;
background-color: #f3f4f6;
font-family: system-ui, sans-serif;
color: #111827;
}
.dashboard {
max-width: 880px;
margin: 0 auto;
}
.dashboard-atas {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.dashboard-atas h1 {
margin: 0;
font-size: 1.5rem;
}
.tombol-muat {
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: #2563eb;
color: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.tombol-muat:hover {
background-color: #1d4ed8;
}
.status {
margin: 0 0 16px;
padding: 10px 12px;
background-color: #e5e7eb;
border-radius: 8px;
}
.daftar-gagal {
margin: 0 0 16px;
padding: 10px 12px 10px 28px;
background-color: #fee2e2;
color: #991b1b;
border-radius: 8px;
}
.daftar-gagal:empty {
display: none;
}
.kartu {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.kartu-item {
padding: 14px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.kartu-item h2 {
margin: 0 0 8px;
font-size: 0.85rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kartu-angka {
margin: 0 0 8px;
font-size: 1.6rem;
font-weight: 700;
}
.kartu-bar {
margin: 0;
font-family: monospace;
font-size: 0.9rem;
color: #2563eb;
letter-spacing: 1px;
}
.konversi {
margin: 0 0 16px;
color: #6b7280;
}
.panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.panel-item {
padding: 14px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.panel-item h2 {
margin: 0 0 10px;
font-size: 1rem;
}
.tabel-transaksi {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.tabel-transaksi th,
.tabel-transaksi td {
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.tabel-transaksi th {
color: #6b7280;
font-weight: 600;
}
.daftar-aktivitas {
margin: 0;
padding-left: 18px;
font-size: 0.9rem;
}
.daftar-aktivitas li {
margin-bottom: 6px;
color: #374151;
}Aturan yang perlu dicermati: .kartu menyusun tiga kartu dalam grid satu baris (Baris 65–70); .kartu-bar memakai font monospace agar petak █ dan ░ rata (Baris 94–100); dan .daftar-gagal:empty { display: none; } (Baris 61–63) menyembunyikan panel kegagalan saat daftarnya kosong — tanpa perlu satu pun if di JavaScript.
dashboard.mjs pada Akhir Bagian 3 #
Berikut dashboard.mjs lengkap pada kondisi akhir Bagian ini. Dibanding versi Bagian 2, berkas ini menambah blok formatter: instance RINGKAS, ringkasAngka, formatPersen, dan buatBar:
// dashboard.mjs — logika inti dashboard data, terpisah dari tampilan (DOM).
// Pemuat data di-inject dari luar (dependency injection): pemuat nyata bisa
// memanggil fetch, sedangkan uji memasang pemuat tiruan yang langsung selesai.
// Seluruh fungsi murni: state masuk, state baru keluar, tanpa DOM.
export async function muatDashboard(pemuat) {
const [statistik, transaksi, aktivitas] = await Promise.all([
pemuat.statistik(),
pemuat.transaksi(),
pemuat.aktivitas(),
]);
return { statistik, transaksi, aktivitas };
}
// Satu tempat menyimpan wujud dashboard; satu-satunya pintu perubahannya
// adalah fungsi-fungsi di bawah — aliran satu arah, state lama tak tersentuh.
export function buatState() {
return { statistik: null, transaksi: [], aktivitas: [], keadaan: 'memuat' };
}
export function setState(state, stateBaru) {
return { ...stateBaru };
}
export function patchState(state, potongan) {
return { ...state, ...potongan };
}
export function terapkanData(state, data) {
return { ...state, ...data, keadaan: 'siap' };
}
// Formatter murni: menerjemahkan angka mentah menjadi teks yang siap tampil.
// Satu Intl.NumberFormat dibuat sekali di luar fungsi agar bisa dipakai ulang.
const RINGKAS = new Intl.NumberFormat('id-ID', {
notation: 'compact',
maximumFractionDigits: 1,
});
export function ringkasAngka(nilai) {
// ICU menyelipkan spasi non-breaking (U+00A0) sebelum satuan; kita rapikan
// menjadi spasi biasa agar tampilan aman di font mana pun.
return RINGKAS.format(nilai).replace(/\u00a0/g, ' ');
}
export function formatPersen(rasio, digit = 1) {
return new Intl.NumberFormat('id-ID', {
style: 'percent',
maximumFractionDigits: digit,
}).format(rasio);
}
export function buatBar(nilai, maks, lebar = 10) {
const terisi = Math.round((nilai / maks) * lebar);
return '█'.repeat(terisi) + '░'.repeat(lebar - terisi);
}Uji Cakupan Bagian 3 #
Formatter murni menghasilkan string, sehingga ujinya paling ringkas di proyek ini — nilai masuk, teks keluar, bandingkan:
describe('ringkasAngka', () => {
test('angka ribuan diringkas menjadi rb', () => {
assert.strictEqual(ringkasAngka(3420), '3,4 rb');
});
test('angka di bawah seribu tampil apa adanya', () => {
assert.strictEqual(ringkasAngka(128), '128');
});
test('angka jutaan diringkas menjadi jt', () => {
assert.strictEqual(ringkasAngka(34500000), '34,5 jt');
});
});describe('buatBar', () => {
test('bar terisi sesuai perbandingan nilai terhadap maksimum', () => {
assert.strictEqual(buatBar(3420, 5000, 10), '███████░░░');
});
test('nilai nol dan nilai maksimum menghasilkan bar kosong dan penuh', () => {
assert.strictEqual(buatBar(0, 100, 10), '░░░░░░░░░░');
assert.strictEqual(buatBar(100, 100, 10), '██████████');
});
test('pecahan petak dibulatkan ke jumlah terdekat', () => {
assert.strictEqual(buatBar(128, 150, 10), '█████████░');
});
});Suite ringkasAngka mengunci tiga wilayah nilai: ribuan menjadi rb, angka di bawah seribu tampil apa adanya tanpa satuan, dan jutaan menjadi jt. Suite buatBar mengunci proporsi (7 dari 10 petak untuk 3420 dari 5000), dua kasus tepi — nilai nol dan nilai maksimum — serta pembulatan pecahan petak: 128 / 150 * 10 = 8,53 dibulatkan menjadi 9. Suite formatPersen melengkapi dengan dua klaim: rasio 0.0374 menjadi 3,7%, dan rasio bulat 0.5 tampil tanpa desimal sebagai 50%.
Pada kondisi kode Bagian 3, node --test dari folder proyek melaporkan:
node --testOutput yang Dihasilkan:
▶ muatDashboard
✔ mengumpulkan data dari ketiga sumber (1.211173ms)
✔ ketiga pemuat dimulai bersamaan dan selesai mengikuti kecepatannya (78.957621ms)
✔ gagal satu sumber menolak keseluruhan muatan (0.934641ms)
✔ muatDashboard (82.438771ms)
▶ buatState
✔ state awal belum berisi data dan masih memuat (0.169732ms)
✔ buatState (0.362074ms)
▶ setState
✔ mengganti state keseluruhan dengan wujud baru (0.241011ms)
✔ mengembalikan salinan baru, bukan object argumen (0.172368ms)
✔ setState (0.631652ms)
▶ patchState
✔ mengubah hanya field yang diberikan (0.16545ms)
✔ state lama tidak ikut berubah (0.073735ms)
✔ patchState (0.389384ms)
▶ terapkanData
✔ menyimpan data muatan dan menandai keadaan siap (0.138445ms)
✔ terapkanData (0.201046ms)
▶ ringkasAngka
✔ angka ribuan diringkas menjadi rb (0.467821ms)
✔ angka di bawah seribu tampil apa adanya (0.128827ms)
✔ angka jutaan diringkas menjadi jt (0.128702ms)
✔ ringkasAngka (0.826387ms)
▶ formatPersen
✔ rasio dikonversi menjadi persen dengan satu desimal (0.337982ms)
✔ rasio yang bulat tampil tanpa desimal (0.165473ms)
✔ formatPersen (0.580806ms)
▶ buatBar
✔ bar terisi sesuai perbandingan nilai terhadap maksimum (0.105503ms)
✔ nilai nol dan nilai maksimum menghasilkan bar kosong dan penuh (0.037703ms)
✔ pecahan petak dibulatkan ke jumlah terdekat (0.052813ms)
✔ buatBar (0.264495ms)
ℹ tests 17
ℹ suites 8
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 160.241993Tujuh belas uji lulus dari delapan suite — suite state dari Bagian 2 tetap hijau, delapan uji formatter dan bar menyusul. duration_ms akan berbeda di mesin Anda.
Ringkasan Bagian 3 #
Angka mentah kini berbicara: 3,4 rb di kartu, bar ███████░░░ di bawahnya, 3,7% konversi, dan tabel transaksi dengan total yang terbaca sekilas. Seluruh terjemahan itu fungsi murni yang teruji, dan antarmuka hanya menempelkan hasilnya. Ada satu asumsi yang belum kita uji, dan asumsi itu sedang dipakai di mana-mana: ketiga sumber data selalu sehat. Apa yang tampil di pengguna ketika satu sumber gagal di tengah jalan — dan bagaimana melaporkannya tanpa membuang dua sumber yang sehat — adalah pekerjaan Bagian 4.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles.css
Unduh berkas/* styles.css — tampilan dashboard data */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px 16px;
background-color: #f3f4f6;
font-family: system-ui, sans-serif;
color: #111827;
}
.dashboard {
max-width: 880px;
margin: 0 auto;
}
.dashboard-atas {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.dashboard-atas h1 {
margin: 0;
font-size: 1.5rem;
}
.tombol-muat {
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: #2563eb;
color: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.tombol-muat:hover {
background-color: #1d4ed8;
}
.status {
margin: 0 0 16px;
padding: 10px 12px;
background-color: #e5e7eb;
border-radius: 8px;
}
.daftar-gagal {
margin: 0 0 16px;
padding: 10px 12px 10px 28px;
background-color: #fee2e2;
color: #991b1b;
border-radius: 8px;
}
.daftar-gagal:empty {
display: none;
}
.kartu {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.kartu-item {
padding: 14px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.kartu-item h2 {
margin: 0 0 8px;
font-size: 0.85rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kartu-angka {
margin: 0 0 8px;
font-size: 1.6rem;
font-weight: 700;
}
.kartu-bar {
margin: 0;
font-family: monospace;
font-size: 0.9rem;
color: #2563eb;
letter-spacing: 1px;
}
.konversi {
margin: 0 0 16px;
color: #6b7280;
}
.panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.panel-item {
padding: 14px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.panel-item h2 {
margin: 0 0 10px;
font-size: 1rem;
}
.tabel-transaksi {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.tabel-transaksi th,
.tabel-transaksi td {
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.tabel-transaksi th {
color: #6b7280;
font-weight: 600;
}
.daftar-aktivitas {
margin: 0;
padding-left: 18px;
font-size: 0.9rem;
}
.daftar-aktivitas li {
margin-bottom: 6px;
color: #374151;
}