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:

js dashboard.mjs — formatter ringkasAngka
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:

  1. Baris 37 (const RINGKAS = new Intl.NumberFormat('id-ID', {): instance formatter dibuat satu kali di luar fungsi. Menyusun Intl.NumberFormat adalah 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.
  2. Baris 38 (notation: 'compact',): notasi ringkas gaya Indonesia — ribuan menjadi rb, jutaan menjadi jt. Angka yang sama akan berbeda di locale lain: formatter yang dibuat dengan locale en-US menghasilkan 3.4K.
  3. Baris 39 (maximumFractionDigits: 1,): paling banyak satu angka di belakang koma — 3420 menjadi 3,4 rb, bukan 3,42 rb.
  4. 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; replace mengubahnya 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));
bash
node demo-formatter.mjs

Output yang Dihasilkan:

text
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:

js dashboard.mjs — formatter formatPersen
export function formatPersen(rasio, digit = 1) {
  return new Intl.NumberFormat('id-ID', {
    style: 'percent',
    maximumFractionDigits: digit,
  }).format(rasio);
}

Mari kita bedah:

  1. Baris 48 (export function formatPersen(rasio, digit = 1) {): fungsi menerima rasio — pecahan antara 0 dan 1 — karena itulah bentuk yang diharapkan style percent: 0.0374 ditampilkan sebagai 3,7%. Parameter digit menentukan jumlah desimal dengan bawaan 1, sehingga pemanggil bisa meminta formatPersen(rasio, 0) tanpa formatter kedua.
  2. Baris 49–52 (return new Intl.NumberFormat('id-ID', { style: 'percent', ... }).format(rasio);): berbeda dari ringkasAngka, 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 seperti RINGKAS tinggal ditiru.

Bar pencapaian dibuat dari teks, bukan gambar:

js dashboard.mjs — builder buatBar
export function buatBar(nilai, maks, lebar = 10) {
  const terisi = Math.round((nilai / maks) * lebar);
  return '█'.repeat(terisi) + '░'.repeat(lebar - terisi);
}

Mari kita bedah:

  1. Baris 56 (const terisi = Math.round((nilai / maks) * lebar);): perbandingan nilai terhadap maksimum dikalikan lebar petak. Untuk 3420 dari sasaran 5000 dengan lebar 10 petak: 3420 / 5000 * 10 = 6,84, dan Math.round membulatkannya menjadi 7 petak terisi.
  2. Baris 57 (return '█'.repeat(terisi) + '░'.repeat(lebar - terisi);): petak penuh direpeat sebanyak terisi, 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/:

html index.html.txt
<!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:

  1. 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.
  2. Baris 50 (const PEMUAT = {): pemuat tiruan di antarmuka — tiga fungsi yang mengembalikan Promise.resolve(...) berisi data contoh. Komentar pada berkas mengingatkan: di aplikasi nyata pemuat ini diganti fetch ke API internal. Course ini tidak memanggil internet mana pun, sehingga halaman contoh tetap hidup tanpa koneksi.
  3. Baris 67 (const KARTU = [): definisi tiga kartu berupa data — kunci field statistik, label yang tampil, dan sasaran untuk bar. Menambah kartu berarti menambah satu baris konfigurasi, bukan menyalin blok render.
  4. Baris 82 (let state = buatState();): satu-satunya variabel state di skrip ini. Seluruh tampilan adalah fungsi dari state ini, persis aturan Bagian 2.
  5. 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.
  6. Baris 89 (const gagal = state.gagal ?? [];): slot laporan kegagalan. Selama logika Bagian 1–2 belum menghasilkan field gagal, ekspresi ini menghasilkan daftar kosong dan panelnya tersembunyi; isinya menyusul saat logika Bagian 4 melaporkan kegagalan per sumber.
  7. Baris 104 (const fragmen = document.createDocumentFragment();): tiga kartu dibangun di dalam fragment lalu ditempel sekali lewat replaceChildren(fragmen) pada Baris 125 — pola render batch yang sama dengan proyek pencarian dan filter, satu penempelan tanpa flicker bertahap.
  8. Baris 120 (bar.textContent = buatBar(nilai, definisi.sasaran, 10);): bar teks dirender sebagai teks biasa; CSS cukup memberi font monospace dan jarak huruf.
  9. **Baris 127–129 (elemenKonversi.textContent = \Konversi: ${formatPersen(…)}`;)**: rasio konversi dihitung dari statistik yang ada — pesanan / pengunjung— lalu diformatformatPersen`.
  10. Baris 131–151 (const badan = elemenPanel.querySelector('tbody'); ...): tabel transaksi dibangun baris demi baris — tr dengan empat td; total memakai Rp diikuti ringkasAngka, sehingga 1250000 tampil sebagai Rp1,3 jt.
  11. 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.
  12. Baris 165–176 (try { ... } catch (galat) { ... } render();): jalur sukses memakai muatDashboard lalu terapkanData; jalur gagal mengganti seluruh state dengan wujud 'galat' lewat setState. Kedua jalur bertemu di render() yang sama pada Baris 176, sehingga tidak ada keadaan yang terlupa digambar.
  13. 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:

css styles.css
/* 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:

js dashboard.mjs
// 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:

js tests/dashboard.test.mjs — suite ringkasAngka
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');
  });
});
js tests/dashboard.test.mjs — suite buatBar
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:

bash
node --test

Output yang Dihasilkan:

text
▶ 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.241993

Tujuh 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
css
/* 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;
}