Kesalahan zona waktu adalah jenis bug yang paling mudah lolos dari pengujian: program berjalan benar di mesin pengembang (yang kebetulan berada di satu zona dengan penguji), lalu menghasilkan jadwal bergeser satu jam di produksi. Indonesia beruntung karena Asia/Jakarta tidak mengenal daylight saving time (DST) — offsetnya +07:00 sepanjang tahun — tetapi begitu aplikasi kita berbicara dengan New York, London, atau Sydney, aturan pergantian jam musiman masuk ke dalam perhitungan. Pada Chapter ini kita kunci dua keterampilan: konversi momen antar zona dengan withZoneSameInstant, dan menghadapi DST dengan benar lewat studi kasus penerbangan lintas zona.

Studi Kasus Jadwal Penerbangan #

Skenario: penerbangan CGK–NRT berangkat dari Jakarta pada 15 Maret 2026 pukul 09:00 WIB dengan durasi terbang 7 jam 15 menit; penerbangan pulang berangkat dari Tokyo pada 20 Maret 2026 pukul 11:00 JST dengan durasi 7 jam 30 menit. Kita ingin menampilkan jam tiba dalam waktu lokal kota tujuan. Strateginya tiga langkah: bangun keberangkatan sebagai ZonedDateTime, geser dengan Duration (waktu fisik di pesawat tidak peduli zona), lalu konversi hasilnya ke zona kota tiba:

import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public class JadwalPenerbangan {
    public static void main(String[] args) {
        DateTimeFormatter tampil = DateTimeFormatter.ofPattern("dd MMMM yyyy HH:mm")
                .withLocale(Locale.forLanguageTag("id"));

        // Penerbangan CGK -> NRT: berangkat dari Jakarta, tiba di Tokyo.
        ZonedDateTime berangkat =
                ZonedDateTime.of(2026, 3, 15, 9, 0, 0, 0, ZoneId.of("Asia/Jakarta"));
        Duration durasiTerbang = Duration.ofHours(7).plusMinutes(15);

        ZonedDateTime tibaTokyo = berangkat.plus(durasiTerbang)
                .withZoneSameInstant(ZoneId.of("Asia/Tokyo"));

        System.out.println("Berangkat (WIB) : " + berangkat.format(tampil));
        System.out.println("Durasi terbang  : " + durasiTerbang);
        System.out.println("Tiba (JST)      : " + tibaTokyo.format(tampil));
        System.out.println("Tiba (UTC)      : " + tibaTokyo.toInstant());

        // Penerbangan pulang NRT -> CGK: berangkat dari Tokyo, tiba di Jakarta.
        ZonedDateTime pulang =
                ZonedDateTime.of(2026, 3, 20, 11, 0, 0, 0, ZoneId.of("Asia/Tokyo"));
        Duration durasiPulang = Duration.ofHours(7).plusMinutes(30);

        ZonedDateTime tibaJakarta = pulang.plus(durasiPulang)
                .withZoneSameInstant(ZoneId.of("Asia/Jakarta"));

        System.out.println("Pulang (JST)    : " + pulang.format(tampil));
        System.out.println("Tiba (WIB)      : " + tibaJakarta.format(tampil));
    }
}

Output yang Dihasilkan:

text
Berangkat (WIB) : 15 Maret 2026 09:00
Durasi terbang  : PT7H15M
Tiba (JST)      : 15 Maret 2026 18:15
Tiba (UTC)      : 2026-03-15T09:15:00Z
Pulang (JST)    : 20 Maret 2026 11:00
Tiba (WIB)      : 20 Maret 2026 16:30

Mari kita telaah baris-baris kode di atas:

  1. Baris 9-10: formatter tampilan dibuat sekali dengan pola dd MMMM yyyy HH:mm dan Locale Indonesia, sehingga bulan tercetak sebagai Maret.
  2. Baris 13-14: keberangkatan dibangun sebagai ZonedDateTime penuh di zona Asia/Jakarta — bukan LocalDateTime — karena momen keberangkatan bersifat absolut: pesawat yang sama terlihat berangkat pukul 09:00 di Jakarta dan pukul 11:00 di Tokyo.
  3. Baris 15 (Duration.ofHours(7).plusMinutes(15)): durasi terbang adalah waktu fisik; tipe yang tepat adalah Duration, bukan Period.
  4. Baris 17-18 (plus(durasi).withZoneSameInstant(...)): penjumlahan durasi bekerja pada garis waktu, lalu withZoneSameInstant menata ulang hasilnya ke zona Tokyo. Tiba pukul 16:15 WIB = 18:15 JST — selisih dua jam mengikuti offset Tokyo +09:00.
  5. Baris 24 (toInstant()): momen tiba juga dicetak dalam UTC (09:15Z) — bentuk yang tepat untuk dikirim ke sistem lain karena bebas tafsir zona.
  6. Baris 27-35: penerbangan pulang mengulang pola yang sama dari sisi Tokyo: 11:00 JST ditambah 7 jam 30 menit menghasilkan 18:30 JST, yang dikonversi menjadi 16:30 WIB di Jakarta.

Perhatikan keputusan tipe di seluruh contoh: jam keberangkatan ZonedDateTime (momen berzona), durasi Duration (waktu fisik), konversi antar zona withZoneSameInstant, dan tampilan akhir memakai formatter dengan Locale eksplisit. Komposisi tipe yang tepat membuat perhitungan menjadi lugas.

Daylight Saving di Zona Lain #

Zona America/New_York mengenal DST: pada 8 Maret 2026 pukul 02:00 setempat, jam meloncat menjadi 03:00 dan offset berubah dari -05:00 menjadi -04:00. Kelas ZoneRules (diperoleh lewat zoneId.getRules()) tahu seluruh jadwal ini, dan ZonedDateTime memakainya secara otomatis:

java ZonaDST.java
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class ZonaDST {
    public static void main(String[] args) {
        ZoneId newYork = ZoneId.of("America/New_York");

        // 8 Maret 2026: daylight saving dimulai di New York (-05:00 -> -04:00).
        ZonedDateTime sebelum = ZonedDateTime.of(2026, 3, 7, 12, 0, 0, 0, newYork);
        ZonedDateTime sesudah = ZonedDateTime.of(2026, 3, 8, 12, 0, 0, 0, newYork);

        System.out.println("7 Mar 12:00 : " + sebelum);
        System.out.println("8 Mar 12:00 : " + sesudah);
        System.out.println("isDaylightSavings 7 Mar  : "
                + newYork.getRules().isDaylightSavings(sebelum.toInstant()));
        System.out.println("isDaylightSavings 8 Mar  : "
                + newYork.getRules().isDaylightSavings(sesudah.toInstant()));

        // plusDays menjaga waktu dinding; plus(Duration) menggeser waktu fisik.
        System.out.println("sebelum.plusDays(1)     : " + sebelum.plusDays(1));
        System.out.println("sebelum.plus 24 jam     : " + sebelum.plus(Duration.ofHours(24)));
        System.out.println("durasi antar dua siang  : "
                + Duration.between(sebelum.toInstant(), sesudah.toInstant()));

        // Jam 02:30 pada 8 Maret tidak pernah ada di New York (meloncat ke 03:00).
        System.out.println("jam 02:30 yang hilang   : "
                + ZonedDateTime.of(2026, 3, 8, 2, 30, 0, 0, newYork));

        // 1 November 2026: DST berakhir; jam 01:30 terjadi dua kali.
        ZonedDateTime ganda = ZonedDateTime.of(2026, 11, 1, 1, 30, 0, 0, newYork);
        System.out.println("jam 01:30 ganda         : " + ganda);
        System.out.println("versi offset akhir      : " + ganda.withLaterOffsetAtOverlap());
    }
}

Output yang Dihasilkan:

text
7 Mar 12:00 : 2026-03-07T12:00-05:00[America/New_York]
8 Mar 12:00 : 2026-03-08T12:00-04:00[America/New_York]
isDaylightSavings 7 Mar  : false
isDaylightSavings 8 Mar  : true
sebelum.plusDays(1)     : 2026-03-08T12:00-04:00[America/New_York]
sebelum.plus 24 jam     : 2026-03-08T13:00-04:00[America/New_York]
durasi antar dua siang  : PT23H
jam 02:30 yang hilang   : 2026-03-08T03:30-04:00[America/New_York]
jam 01:30 ganda         : 2026-11-01T01:30-04:00[America/New_York]
versi offset akhir      : 2026-11-01T01:30-05:00[America/New_York]

Empat baris pertama keluaran menegaskan pergantian offset: siang tanggal 7 Maret beroffset -05:00 dan belum DST, siang tanggal 8 Maret beroffset -04:00 dan sudah DST. Baris plusDays(1) menghasilkan 12:00 lagi pada 8 Maret — penjumlahan hari bekerja pada waktu dinding — padahal Duration.between membuktikan jarak fisiknya hanya PT23H. Sebaliknya plus(Duration.ofHours(24)) menggeser waktu fisik sehingga mendarat di 13:00.

Dua keluaran terakhir memperlihatkan fenomena tepi DST. Pergantian musim semi menciptakan gap: jam 02:30 pada 8 Maret tidak pernah terjadi di New York, dan ZonedDateTime.of menyesuaikannya menjadi 03:30. Saat DST berakhir tercipta overlap: jam 01:30 pada 1 November terjadi dua kali (sekali di offset -04:00, sekali di -05:00); konstruktor memilih offset yang lebih awal, dan method withLaterOffsetAtOverlap() mengambil kembarannya yang kedua.

Dua keterampilan ini — konversi momen antar zona dan menghormati aturan DST — adalah bekal teknis terakhir sebelum kita merangkum semuanya. Bab penutup Section ini menyusun keputusan-keputusan menjadi satu peta: tipe apa untuk kebutuhan apa, format simpan seperti apa, dan kebiasaan apa yang layak dijadikan standar tim.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

JadwalPenerbangan.java

Unduh berkas
java
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public class JadwalPenerbangan {
    public static void main(String[] args) {
        DateTimeFormatter tampil = DateTimeFormatter.ofPattern("dd MMMM yyyy HH:mm")
                .withLocale(Locale.forLanguageTag("id"));

        // Penerbangan CGK -> NRT: berangkat dari Jakarta, tiba di Tokyo.
        ZonedDateTime berangkat =
                ZonedDateTime.of(2026, 3, 15, 9, 0, 0, 0, ZoneId.of("Asia/Jakarta"));
        Duration durasiTerbang = Duration.ofHours(7).plusMinutes(15);

        ZonedDateTime tibaTokyo = berangkat.plus(durasiTerbang)
                .withZoneSameInstant(ZoneId.of("Asia/Tokyo"));

        System.out.println("Berangkat (WIB) : " + berangkat.format(tampil));
        System.out.println("Durasi terbang  : " + durasiTerbang);
        System.out.println("Tiba (JST)      : " + tibaTokyo.format(tampil));
        System.out.println("Tiba (UTC)      : " + tibaTokyo.toInstant());

        // Penerbangan pulang NRT -> CGK: berangkat dari Tokyo, tiba di Jakarta.
        ZonedDateTime pulang =
                ZonedDateTime.of(2026, 3, 20, 11, 0, 0, 0, ZoneId.of("Asia/Tokyo"));
        Duration durasiPulang = Duration.ofHours(7).plusMinutes(30);

        ZonedDateTime tibaJakarta = pulang.plus(durasiPulang)
                .withZoneSameInstant(ZoneId.of("Asia/Jakarta"));

        System.out.println("Pulang (JST)    : " + pulang.format(tampil));
        System.out.println("Tiba (WIB)      : " + tibaJakarta.format(tampil));
    }
}

ZonaDST.java

Unduh berkas
java
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class ZonaDST {
    public static void main(String[] args) {
        ZoneId newYork = ZoneId.of("America/New_York");

        // 8 Maret 2026: daylight saving dimulai di New York (-05:00 -> -04:00).
        ZonedDateTime sebelum = ZonedDateTime.of(2026, 3, 7, 12, 0, 0, 0, newYork);
        ZonedDateTime sesudah = ZonedDateTime.of(2026, 3, 8, 12, 0, 0, 0, newYork);

        System.out.println("7 Mar 12:00 : " + sebelum);
        System.out.println("8 Mar 12:00 : " + sesudah);
        System.out.println("isDaylightSavings 7 Mar  : "
                + newYork.getRules().isDaylightSavings(sebelum.toInstant()));
        System.out.println("isDaylightSavings 8 Mar  : "
                + newYork.getRules().isDaylightSavings(sesudah.toInstant()));

        // plusDays menjaga waktu dinding; plus(Duration) menggeser waktu fisik.
        System.out.println("sebelum.plusDays(1)     : " + sebelum.plusDays(1));
        System.out.println("sebelum.plus 24 jam     : " + sebelum.plus(Duration.ofHours(24)));
        System.out.println("durasi antar dua siang  : "
                + Duration.between(sebelum.toInstant(), sesudah.toInstant()));

        // Jam 02:30 pada 8 Maret tidak pernah ada di New York (meloncat ke 03:00).
        System.out.println("jam 02:30 yang hilang   : "
                + ZonedDateTime.of(2026, 3, 8, 2, 30, 0, 0, newYork));

        // 1 November 2026: DST berakhir; jam 01:30 terjadi dua kali.
        ZonedDateTime ganda = ZonedDateTime.of(2026, 11, 1, 1, 30, 0, 0, newYork);
        System.out.println("jam 01:30 ganda         : " + ganda);
        System.out.println("versi offset akhir      : " + ganda.withLaterOffsetAtOverlap());
    }
}