Tanggal yang Bergeser Sendiri: Pin Zona Waktu Saat Render
Data benar, tampilan salah: toLocaleString tanpa timeZone membuat server UTC dan browser WIB merender tanggal berbeda. Kunci di lapisan presentasi.
Ringkasan
Tanggal tiket kelihatan beda karena toLocaleString dipanggil tanpa opsi timeZone, jadi hasilnya ngikutin zona server. Data UTC-nya aman, cuma lapisan tampilan yang salah render saat hydration di browser WIB. Solusinya pakai satu utilitas formatDateTime yang mengunci Asia/Jakarta, terus dibungkus test biar gak balik lagi.
01.00 WIB, Tanggalnya Besok
Warga membuka halaman status layanan pukul 01.00 WIB dan membaca tanggal pengajuan hari berikutnya. Panel admin, untuk tiket yang sama, menampilkan tanggal hari ini. Data tidak berpindah; yang berbeda hanya siapa yang merender.
Dugaan pertama mengarah ke jam server atau kolom tanggal di database. Pemeriksaan menyatakan semua timestamp tersimpan benar dalam UTC. Yang salah bukan penyimpanan melainkan lapisan render: beberapa titik memanggil toLocaleString dengan locale id-ID tetapi tanpa opsi timeZone. Dokumen MDN menjelaskan hasilnya mengikuti zona waktu mesin yang menjalankan kode[4], dan locale hanya mengatur bahasa serta format, bukan zona waktunya[4].
Kenapa Bedanya Baru Terlihat Sekarang
Selama browser dan server sepakat soal jam, bug ini tidak muncul. Ia muncul ketika server berjalan dalam kontainer UTC sementara browser berada di UTC+7: string yang dirender server berbeda dari hasil hydration di klien. Di framework seperti Next.js, halaman secara default adalah Server Components yang dirender di server lalu dikirim ke klien[5], sehingga hydration wajib mereproduksi keluaran yang sama persis. Timestamp antara 17.00 dan 24.00 UTC jatuh pada hari kalender berikutnya bila dihitung dengan WIB, dan di situlah pengguna mulai melihat tanggal yang bergeser sendiri.
Perbaikannya di Presentasi, Bukan di Data
Keputusan arsitekturnya tegas: data tetap UTC di storage dan API, lapisan presentasi yang mengunci WIB. Satu utilitas formatDateTime menggantikan pemanggilan toLocaleString yang telanjang:
export function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Asia/Jakarta",
});
}
Utilitas ini dipakai di halaman status, ticket flow, dan riwayat admin; timestamp pendek seperti lastSync mendapat opsi timeZone yang sama secara inline.
Verifikasinya bisa dilakukan siapa pun: render timestamp 17.00 UTC dengan dan tanpa opsi timeZone pada mesin UTC+7, lalu bandingkan bagian tanggalnya. Tanpa opsi, tanggal melompat ke besok. Dengan opsi, hasilnya konsisten. Test Vitest mengunci pergeseran +07:00 lintas batas tengah malam UTC sehingga regresi tidak bisa masuk diam-diam.
Simpan UTC, Render dengan Zona Eksplisit
Pola yang bisa dibawa pulang pendek dan berlaku umum: simpan dalam UTC, render dengan zona waktu yang dituliskan eksplisit. Locale mengurus bahasa, timeZone mengurus konsistensi. Keduanya parameter yang berbeda, dan hanya satu yang boleh dititipkan kepada mesin pemanggil.
Setelah perubahan ini, halaman status dan panel admin membaca tanggal yang sama untuk tiket yang sama, kapan pun dan dari zona waktu mana pun halamannya dibuka. Perbedaan yang tadinya tampak seperti bug data ternyata cuma satu opsi yang belum ditulis.
Sumber
- Date.prototype.toLocaleString() - JavaScript | MDN (diakses 2026-10-12).
- Server Components - Next.js (diakses 2026-10-12).