Di akhir Bagian 1, kartu cuaca kita menampilkan JSON mentah: {"kota":"Bandung","suhu":24.6,...}. Data itu sempurna untuk mesin, tetapi pengguna tidak mencari aplikasi untuk membaca JSON — pengguna mencari jawaban singkat: berapa suhunya, lembap atau tidak, dan bagaimana langitnya. Bagian ini menambahkan lapisan penerjemah: 24.6 menjadi 24,6°C, 78 menjadi 78%, dan ketiganya tersusun sebagai kartu yang rapi.
Keputusan desain yang penting: penerjemahan ini ditempatkan sebagai fungsi murni di cuaca.mjs, bukan sebagai kode yang tersebar di antarmuka. Fungsi murni menerima data dan mengembalikan hasil baru tanpa menyentuh DOM sama sekali, sehingga formatnya bisa diuji di terminal — sama seperti logika perhitungan pada proyek kalkulator. Ketika suatu hari kita ingin mengubah tampilan, formatnya tidak ikut berubah; ketika aturan format berubah, ujinya sudah menunggu.
Dua Formatter Intl.NumberFormat #
Menulis 24,6°C dengan tangan kelihatan mudah — sampai kita ingat bahwa pemisah desimal berbeda-beda antar bahasa (24.6 di Inggris, 24,6 di Indonesia) dan penulisan unit punya konvensinya sendiri. Menggabungkan string secara manual berarti menulis ulang pekerjaan yang sudah diselesaikan standar Intl. Karena itu cuaca.mjs menyimpan dua formatter:
// Satu instance formatter dipakai ulang: menyusunnya cukup mahal.
const formatSuhu = new Intl.NumberFormat('id-ID', {
style: 'unit',
unit: 'celsius',
maximumFractionDigits: 1,
});
// Style 'percent' mengharapkan pecahan: 78% ditulis 0,78.
const formatPersen = new Intl.NumberFormat('id-ID', {
style: 'percent',
maximumFractionDigits: 0,
});new Intl.NumberFormat('id-ID', ...): formatter dibuat untuk locale Indonesia, sehingga pemisah desimal otomatis menjadi koma. Formatter yang sama akan menghasilkan24.6°Cbila locale-nya diganti — pemformatan mengikuti bahasa pembaca, bukan di-hardcode.style: 'unit', unit: 'celsius': mode ini menambahkan satuan sesuai konvensi locale;formatSuhu.format(24.6)menghasilkan24,6°C. Perhatikan bahwaIntlyang menentukan penulisannya — hasilnya tidak persis seperti template buatan tangan, dan justru itu tujuannya.maximumFractionDigits: 1: membatasi satu tempat desimal, sehingga31.26tampil sebagai31,3°Calih-alih deretan digit yang tidak bermakna bagi cuaca.style: 'percent': formatter persen mengharapkan pecahan bernilai 0–1; angka78dari server harus dibagi 100 dulu menjadi0.78sebelum diformat menjadi78%. Kesalahan paling umum di formatter ini adalah memasukkan78langsung dan mendapati7.800%.
formatCuaca dan keTeks #
Dua formatter itu dipakai oleh formatCuaca, fungsi murni yang menjadi satu-satunya tempat aturan tampilan data cuaca. Berikut cuaca.mjs lengkap pada akhir Bagian 2 — dibanding versi Bagian 1, berkas ini menambah bagian formatter serta dua fungsi ekspor baru:
// cuaca.mjs — logika inti aplikasi cuaca, terpisah dari tampilan (DOM).
// Tidak ada satu pun kode browser di berkas ini, sehingga seluruh
// perilakunya bisa diuji dengan node --test tanpa membuka halaman web.
// --- Mengambil data: fetch, response.ok, dan ApiError ---
// Galat khusus untuk kegagalan yang berasal dari sisi API/server.
export class ApiError extends Error {
constructor(pesan, status) {
super(pesan);
this.name = 'ApiError';
this.status = status;
}
}
// Server tiruan lokal (node:http) hidup di dalam berkas demo; kita tidak
// memanggil API internet apa pun di course ini. Port 18080 adalah
// kesepakatan proyek: server tiruan menunggu di situ.
const URL_DASAR = 'http://localhost:18080';
// Mengambil data cuaca satu kota dari server.
export async function muatCuaca(kota, { baseUrl = URL_DASAR } = {}) {
const url = `${baseUrl}/cuaca?kota=${encodeURIComponent(kota)}`;
const respons = await fetch(url);
if (!respons.ok) {
throw new ApiError(`Server menjawab ${respons.status} untuk kota "${kota}"`, respons.status);
}
const data = await respons.json();
if (typeof data.kota !== 'string' || typeof data.suhu !== 'number') {
throw new ApiError('Format data cuaca tidak dikenal', respons.status);
}
return data;
}
// --- Menampilkan data: formatter murni ---
// Satu instance formatter dipakai ulang: menyusunnya cukup mahal.
const formatSuhu = new Intl.NumberFormat('id-ID', {
style: 'unit',
unit: 'celsius',
maximumFractionDigits: 1,
});
// Style 'percent' mengharapkan pecahan: 78% ditulis 0,78.
const formatPersen = new Intl.NumberFormat('id-ID', {
style: 'percent',
maximumFractionDigits: 0,
});
// Mengubah data mentah dari server menjadi objek tampilan yang siap
// dirender: semua nilainya sudah berupa teks berformat.
export function formatCuaca(data) {
return {
judul: `Cuaca di ${data.kota}`,
suhu: formatSuhu.format(data.suhu),
kelembapan: formatPersen.format(data.kelembapan / 100),
deskripsi: data.deskripsi,
};
}
// Merangkai objek tampilan menjadi satu teks multi baris — berguna untuk
// demo di terminal maupun pratinjau cepat.
export function keTeks(tampilan) {
return [
tampilan.judul,
`Suhu : ${tampilan.suhu}`,
`Kelembapan: ${tampilan.kelembapan}`,
`Kondisi : ${tampilan.deskripsi}`,
].join('\n');
}Mari kita bedah kedua fungsi barunya:
formatCuaca(data): data mentah masuk, dan keluarannya adalah objek tampilan — empat properti yang semuanya sudah berupa teks siap render:judul,suhu,kelembapan, dandeskripsi. Fungsi ini tidak memilih elemen HTML dan tidak menyimpan apa pun; perannya berhenti di “menyiapkan bahan”.formatPersen.format(data.kelembapan / 100): di sinilah konversi pecahan terjadi —78dari server menjadi0.78sebelum diformat menjadi78%.keTeks(tampilan): merangkai objek tampilan menjadi satu string multi baris denganjoin('\n'). Fungsi ini bukan kewajiban antarmuka — perannya adalah membuat hasilformatCuacabisa dipamerkan di terminal, yang berarti juga bisa diuji dan didemokan tanpa browser.
Karena keduanya murni, demo bagian ini bahkan tidak perlu server tiruan sama sekali:
// demo-format.mjs — mencoba formatCuaca dan keTeks tanpa jaringan maupun
// browser. Kedua fungsi ini murni: data polos masuk, teks baru keluar.
import { formatCuaca, keTeks } from './cuaca.mjs';
const bandung = { kota: 'Bandung', suhu: 24.6, kelembapan: 78, deskripsi: 'Berawan sebagian' };
const surabaya = { kota: 'Surabaya', suhu: 31.2, kelembapan: 65, deskripsi: 'Cerah berawan' };
console.log(JSON.stringify(formatCuaca(bandung), null, 2));
console.log('---');
console.log(keTeks(formatCuaca(bandung)));
console.log('---');
console.log(keTeks(formatCuaca(surabaya)));Jalankan dari folder bagian ini:
node demo-format.mjsOutput yang Dihasilkan:
{
"judul": "Cuaca di Bandung",
"suhu": "24,6°C",
"kelembapan": "78%",
"deskripsi": "Berawan sebagian"
}
---
Cuaca di Bandung
Suhu : 24,6°C
Kelembapan: 78%
Kondisi : Berawan sebagian
---
Cuaca di Surabaya
Suhu : 31,2°C
Kelembapan: 65%
Kondisi : Cerah berawanBlok JSON pertama memperlihatkan wujud objek tampilan: nilai-nilainya kini string berformat, bukan angka mentah. Dua blok berikutnya adalah hasil keTeks untuk dua kota — perhatikan koma desimal pada 24,6 dan 31,2 yang bekerja otomatis berkat locale id-ID. Seluruh demo berjalan tanpa satu pun permintaan jaringan: data polos disuntikkan langsung, persis seperti yang akan dilakukan uji.
Kartu Cuaca di Antarmuka #
Dengan objek tampilan siap pakai, skrip antarmuka tinggal merendernya. index.html.txt versi Bagian 2 mengganti tampilan JSON mentah dengan kartu berjudul dan daftar rincian:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aplikasi Cuaca</title>
<link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
<main class="panel">
<h1>Cuaca Kota</h1>
<form id="formulir-cari">
<input
id="kolom-kota"
name="kota"
type="text"
placeholder="Contoh: Bandung"
autocomplete="off"
>
<button type="submit">Lihat</button>
</form>
<section id="kartu-cuaca" class="kartu">
<p>Ketik nama kota untuk melihat cuacanya.</p>
</section>
</main>
<script type="module">
import { formatCuaca, muatCuaca } from './cuaca.mjs';
const formulir = document.querySelector('#formulir-cari');
const kartu = document.querySelector('#kartu-cuaca');
function buatItem(label, nilai) {
const item = document.createElement('li');
item.textContent = `${label}: ${nilai}`;
return item;
}
function renderTampilan(tampilan) {
kartu.textContent = '';
const judul = document.createElement('h2');
judul.textContent = tampilan.judul;
const daftar = document.createElement('ul');
daftar.append(buatItem('Suhu', tampilan.suhu));
daftar.append(buatItem('Kelembapan', tampilan.kelembapan));
daftar.append(buatItem('Kondisi', tampilan.deskripsi));
kartu.append(judul, daftar);
}
formulir.addEventListener('submit', async (event) => {
event.preventDefault();
const kota = new FormData(formulir).get('kota').trim();
if (kota === '') return;
try {
const data = await muatCuaca(kota);
renderTampilan(formatCuaca(data));
} catch (galat) {
kartu.textContent = `Gagal memuat data: ${galat.message}`;
}
});
</script>
</body>
</html>Yang berubah dibanding Bagian 1 hanya skripnya:
renderTampilan(tampilan): menerima objek hasilformatCuaca— bukan data mentah — lalu membangun elemen dengancreateElementdan mengisinya lewattextContent. Tidak ada satu pun string HTML yang digabung manual, sehingga isi data tidak pernah ditafsirkan sebagai markup.kartu.textContent = '': mengosongkan kartu sebelum isi baru ditempel, supaya hasil pencarian lama tidak menumpuk di bawah hasil baru.buatItem(label, nilai): pembantu kecil untuk satu baris daftar; tiga pemanggilannya menggantikan tiga blok kode yang hampir identik.
CSS juga bertambah tiga aturan untuk struktur baru di dalam kartu — judul, daftar, dan jarak antaritemnya:
/* styles.css — tampilan aplikasi cuaca */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #e0f2fe;
font-family: system-ui, sans-serif;
}
.panel {
width: 300px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.panel h1 {
margin: 0 0 12px;
font-size: 1.25rem;
color: #0c4a6e;
}
#formulir-cari {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
#kolom-kota {
flex: 1;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 1rem;
}
#formulir-cari button {
padding: 8px 14px;
border: none;
border-radius: 8px;
background-color: #0284c7;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
#formulir-cari button:hover {
background-color: #0369a1;
}
.kartu {
min-height: 80px;
padding: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.95rem;
color: #334155;
white-space: pre-wrap;
}
.kartu h2 {
margin: 0 0 8px;
font-size: 1.1rem;
color: #0c4a6e;
}
.kartu ul {
margin: 0;
padding-left: 18px;
}
.kartu li {
margin-bottom: 4px;
}Aturan .kartu h2 menurunkan ukuran judul kota agar tidak bersaing dengan judul panel, sementara .kartu ul dan .kartu li merapikan daftar rincian — margin bawaan ul dihapus dan jarak antaritem dibuat konsisten. Kartu kini menampilkan sesuatu seperti Cuaca di Bandung diikuti tiga baris: Suhu: 24,6°C, Kelembapan: 78%, dan Kondisi: Berawan sebagian.
Menguji Format Secara Murni #
Karena formatCuaca tidak menyentuh DOM, ujinya sama sekali tidak membutuhkan browser — cukup data polos dan perbandingan hasil. Kelompok uji berikut ditambahkan ke tests/cuaca.test.mjs:
import { ApiError, formatCuaca, keTeks, muatCuaca } from '../src/cuaca.mjs';
// ... server tiruan dan kelompok uji muatCuaca tetap sama ...
describe('formatCuaca', () => {
test('memformat suhu, kelembapan, dan deskripsi', () => {
assert.deepStrictEqual(formatCuaca(DATA_CUACA.bandung), {
judul: 'Cuaca di Bandung',
suhu: '24,6°C',
kelembapan: '78%',
deskripsi: 'Berawan sebagian',
});
});
test('membatasi suhu ke satu tempat desimal', () => {
const tampilan = formatCuaca({ kota: 'Medan', suhu: 31.26, kelembapan: 84, deskripsi: 'Hujan ringan' });
assert.strictEqual(tampilan.suhu, '31,3°C');
});
test('menyusun baris teks siap tampilkan', () => {
const teks = keTeks(formatCuaca(DATA_CUACA.bandung));
assert.strictEqual(
teks,
'Cuaca di Bandung\nSuhu : 24,6°C\nKelembapan: 78%\nKondisi : Berawan sebagian',
);
});
});Jalankan kembali dari folder proyek:
node --testOutput yang Dihasilkan:
▶ muatCuaca
✔ mengembalikan data JSON untuk kota yang tersedia (24.400531ms)
✔ melempar ApiError berstatus 404 untuk kota yang tidak ditemukan (4.779857ms)
✔ mengodekan nama kota menjadi URL yang aman (1.693917ms)
✔ muatCuaca (33.329404ms)
▶ formatCuaca
✔ memformat suhu, kelembapan, dan deskripsi (0.466097ms)
✔ membatasi suhu ke satu tempat desimal (0.244145ms)
✔ menyusun baris teks siap tampilkan (0.204647ms)
✔ formatCuaca (1.153358ms)
ℹ tests 6
ℹ suites 2
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 166.179017Enam uji lulus. Uji “membatasi suhu ke satu tempat desimal” sengaja memakai nilai yang butuh pembulatan — 31.26 menjadi 31,3°C — agar aturan maximumFractionDigits: 1 tidak hanya terpenuhi secara kebetulan. Uji keTeks membandingkan string lengkap termasuk \n dan spasi penyelarasnya; bila suatu hari seseorang mengubah tata letak teks ini, uji akan memberi tahu bahwa kontraknya berubah.
Ringkasan Bagian 2 #
Aplikasi kita kini berbicara dua bahasa dengan sopan: JSON dengan server, dan teks berformat locale Indonesia dengan pengguna — keduanya dipisahkan oleh fungsi murni formatCuaca yang seluruh keluarannya terkunci uji. Yang belum rapi adalah pengalaman saat menunggu dan saat gagal: selama fetch berjalan, kartu masih menampilkan hasil pencarian sebelumnya; saat server menjawab 404, yang tampil adalah pesan teknis Server menjawab 404 untuk kota "Bulan". Bagian 3 menata keduanya dengan state machine.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles.css
Unduh berkas/* styles.css — tampilan aplikasi cuaca */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #e0f2fe;
font-family: system-ui, sans-serif;
}
.panel {
width: 300px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.panel h1 {
margin: 0 0 12px;
font-size: 1.25rem;
color: #0c4a6e;
}
#formulir-cari {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
#kolom-kota {
flex: 1;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 1rem;
}
#formulir-cari button {
padding: 8px 14px;
border: none;
border-radius: 8px;
background-color: #0284c7;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
#formulir-cari button:hover {
background-color: #0369a1;
}
.kartu {
min-height: 80px;
padding: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.95rem;
color: #334155;
white-space: pre-wrap;
}
.kartu h2 {
margin: 0 0 8px;
font-size: 1.1rem;
color: #0c4a6e;
}
.kartu ul {
margin: 0;
padding-left: 18px;
}
.kartu li {
margin-bottom: 4px;
}