From 63b84a54e9f621113d738a0c722c06a8622d71af Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:07:57 +0700 Subject: [PATCH] feat: enhance DateService with timezone support and improved date manipulation methods --- apps/docs-dev/stories/button.stories.tsx | 2 +- apps/web/src/apps/example/date-example.tsx | 183 +++++++----- packages/ui/src/components/date-example.tsx | 183 +++++++----- packages/utils/src/date-service/index.ts | 304 +++++++++++++++----- 4 files changed, 469 insertions(+), 203 deletions(-) diff --git a/apps/docs-dev/stories/button.stories.tsx b/apps/docs-dev/stories/button.stories.tsx index aca4c8e..bfd9ecf 100644 --- a/apps/docs-dev/stories/button.stories.tsx +++ b/apps/docs-dev/stories/button.stories.tsx @@ -35,7 +35,7 @@ export const Primary: Story = { name: 'Button', args: { children: 'Hello', - type: 'button', + type: 'submit', style: { color: 'blue', border: '1px solid gray', diff --git a/apps/web/src/apps/example/date-example.tsx b/apps/web/src/apps/example/date-example.tsx index 38ec2d0..bee5a39 100644 --- a/apps/web/src/apps/example/date-example.tsx +++ b/apps/web/src/apps/example/date-example.tsx @@ -1,47 +1,53 @@ import { DateService } from '@repo/utils'; import React, { useState, useEffect, useMemo } from 'react'; -// Mock Data untuk simulasi list tugas/tagihan -// Kita gunakan relative time agar mock data selalu relevan saat dijalankan -const MOCK_TASKS = [ - { - id: 1, - title: 'Bayar Server (Lusa)', - dueDate: DateService.now().add(2, 'day').toISOString(), - }, - { - id: 2, - title: 'Laporan Bulanan (Lewat 5 Hari)', - dueDate: DateService.now().subtract(5, 'day').toISOString(), - }, - { - id: 3, - title: 'Meeting Tahunan (Bulan Depan)', - dueDate: DateService.now().add(1, 'month').toISOString(), - }, - { - id: 4, - title: 'Deadline Besok Pagi', - dueDate: DateService.now().add(1, 'day').startOf('day').toISOString(), - }, -]; - export const DateServiceExample: React.FC = () => { - // 1. STATE: Waktu sekarang (untuk jam digital) - const [currentTime, setCurrentTime] = useState(DateService.now()); + // --- 0. TIMEZONE CONFIGURATION (ROOT CONTROL) --- + const [currentZone, setCurrentZone] = useState(DateService.getGlobalTimezone()); - // 2. STATE: Manipulasi tanggal interaktif + // Ambil list timezone dari helper static yang kita buat sebelumnya + const timezoneList = useMemo(() => DateService.getSupportedTimezones(), []); + + // --- 1. STATE & DATA --- + + // MOCK DATA dipindahkan ke sini & pakai useMemo + // Agar saat timezone ganti, tasks ini di-regenerate dengan waktu "Now" versi timezone baru. + const tasks = useMemo(() => { + return [ + { + id: 1, + title: 'Bayar Server (Lusa)', + dueDate: DateService.now().add(2, 'day').toISOString(), + }, + { + id: 2, + title: 'Laporan Bulanan (Lewat 5 Hari)', + dueDate: DateService.now().subtract(5, 'day').toISOString(), + }, + { + id: 3, + title: 'Meeting Tahunan (Bulan Depan)', + dueDate: DateService.now().add(1, 'month').toISOString(), + }, + { + id: 4, + title: 'Deadline Besok Pagi', + dueDate: DateService.now().add(1, 'day').startOf('day').toISOString(), + }, + ]; + }, [currentZone]); // Dependency: currentZone + + const [currentTime, setCurrentTime] = useState(DateService.now()); const [selectedDate, setSelectedDate] = useState(DateService.now()); - // 3. STATE: Khusus Demo Logic Calendar (Besok jam 00:00 vs Sekarang) - // Ini untuk menjawab kenapa diff kadang 0 padahal beda hari - const [calendarTarget] = useState(() => DateService.now().add(1, 'day').startOf('day')); + // State demo logic calendar + const [calendarTarget, setCalendarTarget] = useState(() => DateService.now().add(1, 'day').startOf('day')); - // 4. MEMO: Nilai Epoch Static (Hanya diambil saat component mount) - // Gunakan ini jika ingin nilai 'kapan user membuka halaman' + // Static date (Memo kosong) -> Akan tetap di Timezone saat pertama kali load (kecuali di-refresh) + // Ini bagus untuk demo bahwa object lama tidak berubah (immutable) kecuali dibuat ulang. const staticDate = useMemo(() => new DateService(), []); - // Effect untuk update jam setiap detik + // Effect update jam tiap detik useEffect(() => { const timer = setInterval(() => { setCurrentTime(DateService.now()); @@ -49,25 +55,79 @@ export const DateServiceExample: React.FC = () => { return () => clearInterval(timer); }, []); - // Handler untuk tombol tambah/kurang hari + // --- HANDLER GANTI TIMEZONE --- + const handleZoneChange = (e: React.ChangeEvent) => { + const newZone = e.target.value; + setCurrentZone(newZone); + + // 1. Set Global Config (Inti Perubahan) + DateService.setGlobalConfig(newZone); + + // 2. Force Refresh State agar UI langsung berubah + // Kita buat instance baru agar mengambil config timezone terbaru + setCurrentTime(DateService.now()); + + // Kita recreate selectedDate dengan nilai raw yang sama, tapi context timezone baru + setSelectedDate((prev) => new DateService(prev.getRaw())); + + // Recreate target demo calendar + setCalendarTarget(DateService.now().add(1, 'day').startOf('day')); + }; + const handleAddDay = (days: number) => { setSelectedDate((prev) => prev.add(days, 'day')); }; return (
-

DateService Feature Showcase

+ {/* HEADER & TIMEZONE CONTROLLER */} +
+

DateService Feature Showcase

+ +
+ + +
+
{/* --- CARD 1: REALTIME CLOCK --- */}

Realtime Clock

-
{currentTime.format('HH:mm:ss')}
-
{currentTime.format('dddd, DD MMMM YYYY')}
+ +
+
{currentTime.format('HH:mm:ss')}
+ +
+ {/* UPDATE DI SINI: Gunakan .timezoneAbbr */} +
+ {/* Ini akan output "WIB", "WITA", atau "EST" */} + {currentTime.timezoneAbbr} + + {/* Ini offset angka (+07:00) */} + ({currentTime.format('Z')}) +
+
+
+ +
{currentTime.format('dddd, DD MMMM YYYY')}
- ISO: {currentTime.toISOString()} + ISO (UTC): {currentTime.toISOString()}
@@ -79,7 +139,7 @@ export const DateServiceExample: React.FC = () => { {/* STATIC SECTION */}
STATIC (Load Once)
-
Captured via useMemo([]), tidak berubah.
+
Captured on mount (keeps original timezone).
Epoch Millis
@@ -95,7 +155,7 @@ export const DateServiceExample: React.FC = () => { {/* LIVE SECTION */}
LIVE (Reactive)
-
Updated every second (new instance).
+
Updated every second (follows dropdown).
Epoch Millis
@@ -110,49 +170,44 @@ export const DateServiceExample: React.FC = () => {
- {/* --- CARD 3: CALENDAR LOGIC DEMO (Standard vs Calendar Diff) --- */} + {/* --- CARD 3: CALENDAR LOGIC DEMO --- */}

🗓️ Calendar Logic Demo (The "Zero Day" Issue)

- {/* Info Section */}

Compare Current Time vs Tomorrow 00:00.

- Now: + Now ({currentZone}): {currentTime.format('HH:mm')}
- Target: + Target (Start of Day): {calendarTarget.format('DD MMM HH:mm')}
- {/* Wrong Way */}
Standard .diff('day')
{calendarTarget.diff(currentTime, 'day')}{' '} Days
-

- Hasil 0 jika jarak < 24 jam. Kurang cocok untuk deadline "H-1". -

+

Hasil 0 jika jarak < 24 jam.

- {/* Right Way */}
New .diffCalendarDay()
{calendarTarget.diffCalendarDay(currentTime)}{' '} Days
-

Hasil 1. Menghitung selisih tanggal kalender (ignore jam).

+

Hasil 1. Benar secara kalender.

@@ -174,6 +229,7 @@ export const DateServiceExample: React.FC = () => {
{selectedDate.format('DD MMMM YYYY')} Selected Date State + Offset: {selectedDate.format('Z')}
- {/* --- CARD 5: TASK LIST (IMPLEMENTATION REAL) --- */} + {/* --- CARD 5: TASK LIST (DYNAMIC) --- */}
-

Task List Implementation

+

+ Task List Implementation (Timezone Aware) +

@@ -212,22 +270,16 @@ export const DateServiceExample: React.FC = () => { - + - {MOCK_TASKS.map((task) => { + {tasks.map((task) => { const dueDate = new DateService(task.dueDate); - const now = DateService.now(); // Mengambil waktu 'live' dari state currentTime akan menyebabkan re-render tiap detik, tapi disini kita instantiate baru agar logic per baris bersih. + const now = DateService.now(); - // Logic 1: Apakah sudah lewat? (Check absolute timestamp) const isOverdue = now.isAfter(dueDate); - - // Logic 2: Gunakan CALENDAR DIFF untuk countdown yang enak dibaca user - // Jika pakai diff() biasa, tugas besok pagi bisa dianggap '0 days left' const daysDiff = dueDate.diffCalendarDay(now); - - // Logic 3: Warning jika kurang dari 3 hari lagi (dan belum overdue) const isDueSoon = !isOverdue && daysDiff <= 3; let statusBadge; @@ -256,11 +308,14 @@ export const DateServiceExample: React.FC = () => { return ( - +
Task Name Due Date Status BadgeHuman Readable CountdownCountdown
{task.title}{dueDate.format('DD MMM YYYY HH:mm')} + {dueDate.format('DD MMM YYYY HH:mm')} + {dueDate.format('Z')} + {statusBadge} {isOverdue ? ( - `${Math.abs(daysDiff)} days late` // Gunakan Math.abs karena diff negatif + `${Math.abs(daysDiff)} days late` ) : daysDiff === 0 ? ( Today! ) : ( diff --git a/packages/ui/src/components/date-example.tsx b/packages/ui/src/components/date-example.tsx index 5654ed4..42f8fa9 100644 --- a/packages/ui/src/components/date-example.tsx +++ b/packages/ui/src/components/date-example.tsx @@ -1,47 +1,53 @@ import { DateService } from '@repo/utils'; import React, { useState, useEffect, useMemo } from 'react'; -// Mock Data untuk simulasi list tugas/tagihan -// Kita gunakan relative time agar mock data selalu relevan saat dijalankan -const MOCK_TASKS = [ - { - id: 1, - title: 'Bayar Server (Lusa)', - dueDate: DateService.now().add(2, 'day').toISOString(), - }, - { - id: 2, - title: 'Laporan Bulanan (Lewat 5 Hari)', - dueDate: DateService.now().subtract(5, 'day').toISOString(), - }, - { - id: 3, - title: 'Meeting Tahunan (Bulan Depan)', - dueDate: DateService.now().add(1, 'month').toISOString(), - }, - { - id: 4, - title: 'Deadline Besok Pagi', - dueDate: DateService.now().add(1, 'day').startOf('day').toISOString(), - }, -]; - export const DateServiceComponent: React.FC = () => { - // 1. STATE: Waktu sekarang (untuk jam digital) - const [currentTime, setCurrentTime] = useState(DateService.now()); + // --- 0. TIMEZONE CONFIGURATION (ROOT CONTROL) --- + const [currentZone, setCurrentZone] = useState(DateService.getGlobalTimezone()); - // 2. STATE: Manipulasi tanggal interaktif + // Ambil list timezone dari helper static yang kita buat sebelumnya + const timezoneList = useMemo(() => DateService.getSupportedTimezones(), []); + + // --- 1. STATE & DATA --- + + // MOCK DATA dipindahkan ke sini & pakai useMemo + // Agar saat timezone ganti, tasks ini di-regenerate dengan waktu "Now" versi timezone baru. + const tasks = useMemo(() => { + return [ + { + id: 1, + title: 'Bayar Server (Lusa)', + dueDate: DateService.now().add(2, 'day').toISOString(), + }, + { + id: 2, + title: 'Laporan Bulanan (Lewat 5 Hari)', + dueDate: DateService.now().subtract(5, 'day').toISOString(), + }, + { + id: 3, + title: 'Meeting Tahunan (Bulan Depan)', + dueDate: DateService.now().add(1, 'month').toISOString(), + }, + { + id: 4, + title: 'Deadline Besok Pagi', + dueDate: DateService.now().add(1, 'day').startOf('day').toISOString(), + }, + ]; + }, [currentZone]); // Dependency: currentZone + + const [currentTime, setCurrentTime] = useState(DateService.now()); const [selectedDate, setSelectedDate] = useState(DateService.now()); - // 3. STATE: Khusus Demo Logic Calendar (Besok jam 00:00 vs Sekarang) - // Ini untuk menjawab kenapa diff kadang 0 padahal beda hari - const [calendarTarget] = useState(() => DateService.now().add(1, 'day').startOf('day')); + // State demo logic calendar + const [calendarTarget, setCalendarTarget] = useState(() => DateService.now().add(1, 'day').startOf('day')); - // 4. MEMO: Nilai Epoch Static (Hanya diambil saat component mount) - // Gunakan ini jika ingin nilai 'kapan user membuka halaman' + // Static date (Memo kosong) -> Akan tetap di Timezone saat pertama kali load (kecuali di-refresh) + // Ini bagus untuk demo bahwa object lama tidak berubah (immutable) kecuali dibuat ulang. const staticDate = useMemo(() => new DateService(), []); - // Effect untuk update jam setiap detik + // Effect update jam tiap detik useEffect(() => { const timer = setInterval(() => { setCurrentTime(DateService.now()); @@ -49,25 +55,79 @@ export const DateServiceComponent: React.FC = () => { return () => clearInterval(timer); }, []); - // Handler untuk tombol tambah/kurang hari + // --- HANDLER GANTI TIMEZONE --- + const handleZoneChange = (e: React.ChangeEvent) => { + const newZone = e.target.value; + setCurrentZone(newZone); + + // 1. Set Global Config (Inti Perubahan) + DateService.setGlobalConfig(newZone); + + // 2. Force Refresh State agar UI langsung berubah + // Kita buat instance baru agar mengambil config timezone terbaru + setCurrentTime(DateService.now()); + + // Kita recreate selectedDate dengan nilai raw yang sama, tapi context timezone baru + setSelectedDate((prev) => new DateService(prev.getRaw())); + + // Recreate target demo calendar + setCalendarTarget(DateService.now().add(1, 'day').startOf('day')); + }; + const handleAddDay = (days: number) => { setSelectedDate((prev) => prev.add(days, 'day')); }; return (
-

DateService Feature Showcase

+ {/* HEADER & TIMEZONE CONTROLLER */} +
+

DateService Feature Showcase

+ +
+ + +
+
{/* --- CARD 1: REALTIME CLOCK --- */}

Realtime Clock

-
{currentTime.format('HH:mm:ss')}
-
{currentTime.format('dddd, DD MMMM YYYY')}
+ +
+
{currentTime.format('HH:mm:ss')}
+ +
+ {/* UPDATE DI SINI: Gunakan .timezoneAbbr */} +
+ {/* Ini akan output "WIB", "WITA", atau "EST" */} + {currentTime.timezoneAbbr} + + {/* Ini offset angka (+07:00) */} + ({currentTime.format('Z')}) +
+
+
+ +
{currentTime.format('dddd, DD MMMM YYYY')}
- ISO: {currentTime.toISOString()} + ISO (UTC): {currentTime.toISOString()}
@@ -79,7 +139,7 @@ export const DateServiceComponent: React.FC = () => { {/* STATIC SECTION */}
STATIC (Load Once)
-
Captured via useMemo([]), tidak berubah.
+
Captured on mount (keeps original timezone).
Epoch Millis
@@ -95,7 +155,7 @@ export const DateServiceComponent: React.FC = () => { {/* LIVE SECTION */}
LIVE (Reactive)
-
Updated every second (new instance).
+
Updated every second (follows dropdown).
Epoch Millis
@@ -110,49 +170,44 @@ export const DateServiceComponent: React.FC = () => {
- {/* --- CARD 3: CALENDAR LOGIC DEMO (Standard vs Calendar Diff) --- */} + {/* --- CARD 3: CALENDAR LOGIC DEMO --- */}

🗓️ Calendar Logic Demo (The "Zero Day" Issue)

- {/* Info Section */}

Compare Current Time vs Tomorrow 00:00.

- Now: + Now ({currentZone}): {currentTime.format('HH:mm')}
- Target: + Target (Start of Day): {calendarTarget.format('DD MMM HH:mm')}
- {/* Wrong Way */}
Standard .diff('day')
{calendarTarget.diff(currentTime, 'day')}{' '} Days
-

- Hasil 0 jika jarak < 24 jam. Kurang cocok untuk deadline "H-1". -

+

Hasil 0 jika jarak < 24 jam.

- {/* Right Way */}
New .diffCalendarDay()
{calendarTarget.diffCalendarDay(currentTime)}{' '} Days
-

Hasil 1. Menghitung selisih tanggal kalender (ignore jam).

+

Hasil 1. Benar secara kalender.

@@ -174,6 +229,7 @@ export const DateServiceComponent: React.FC = () => {
{selectedDate.format('DD MMMM YYYY')} Selected Date State + Offset: {selectedDate.format('Z')}
- {/* --- CARD 5: TASK LIST (IMPLEMENTATION REAL) --- */} + {/* --- CARD 5: TASK LIST (DYNAMIC) --- */}
-

Task List Implementation

+

+ Task List Implementation (Timezone Aware) +

@@ -212,22 +270,16 @@ export const DateServiceComponent: React.FC = () => { - + - {MOCK_TASKS.map((task) => { + {tasks.map((task) => { const dueDate = new DateService(task.dueDate); - const now = DateService.now(); // Mengambil waktu 'live' dari state currentTime akan menyebabkan re-render tiap detik, tapi disini kita instantiate baru agar logic per baris bersih. + const now = DateService.now(); - // Logic 1: Apakah sudah lewat? (Check absolute timestamp) const isOverdue = now.isAfter(dueDate); - - // Logic 2: Gunakan CALENDAR DIFF untuk countdown yang enak dibaca user - // Jika pakai diff() biasa, tugas besok pagi bisa dianggap '0 days left' const daysDiff = dueDate.diffCalendarDay(now); - - // Logic 3: Warning jika kurang dari 3 hari lagi (dan belum overdue) const isDueSoon = !isOverdue && daysDiff <= 3; let statusBadge; @@ -256,11 +308,14 @@ export const DateServiceComponent: React.FC = () => { return ( - +
Task Name Due Date Status BadgeHuman Readable CountdownCountdown
{task.title}{dueDate.format('DD MMM YYYY HH:mm')} + {dueDate.format('DD MMM YYYY HH:mm')} + {dueDate.format('Z')} + {statusBadge} {isOverdue ? ( - `${Math.abs(daysDiff)} days late` // Gunakan Math.abs karena diff negatif + `${Math.abs(daysDiff)} days late` ) : daysDiff === 0 ? ( Today! ) : ( diff --git a/packages/utils/src/date-service/index.ts b/packages/utils/src/date-service/index.ts index f5e8e75..f385b3b 100644 --- a/packages/utils/src/date-service/index.ts +++ b/packages/utils/src/date-service/index.ts @@ -1,64 +1,196 @@ +/** + * ------------------------------------------------------------ + * DateService + * ------------------------------------------------------------ + * Centralized date and time utility built on top of Day.js. + * + * Features: + * - Global timezone normalization + * - Safe cloning with timezone consistency + * - Fluent and immutable API + * - ISO 8601 compliant output + * - Explicit Indonesian timezone abbreviation support + * + * ⚠️ Day.js plugins MUST be initialized before using DateService. + * ------------------------------------------------------------ + */ + import dayjs, { Dayjs, OpUnitType, ManipulateType } from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import advancedFormat from 'dayjs/plugin/advancedFormat'; + +/* ------------------------------------------------------------------ + * Day.js Bootstrap + * ------------------------------------------------------------------ */ + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(advancedFormat); + +/* ------------------------------------------------------------------ + * Types + * ------------------------------------------------------------------ */ /** - * FIX: Tambahkan 'Dayjs' ke dalam union type ini. - * Ini memberitahu TS bahwa input boleh berupa string, date, atau object Dayjs itu sendiri. + * Accepted input formats for DateService. */ export type DateInput = string | number | Date | Dayjs | null | undefined; -export type TimeUnit = 'day' | 'week' | 'month' | 'year' | 'hour' | 'minute' | 'second'; +/** + * Supported time units for manipulation and comparison. + */ +export type TimeUnit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second'; +/* ------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------ */ + +/** + * Explicit mapping for Indonesian timezone abbreviations. + * This avoids ambiguity and ensures consistent output. + */ +const INDONESIA_TZ_MAP: Record = { + 'Asia/Jakarta': 'WIB', + 'Asia/Pontianak': 'WIB', + 'Asia/Bangkok': 'WIB', + 'Asia/Makassar': 'WITA', + 'Asia/Ujung_Pandang': 'WITA', + 'Asia/Jayapura': 'WIT', +}; + +/* ------------------------------------------------------------------ + * Interfaces + * ------------------------------------------------------------------ */ + +/** + * Fluent date manipulation interface. + * All methods are immutable and return a new instance. + */ interface IDateManager { - format(formatString?: string): string; + format(format?: string): string; add(value: number, unit: TimeUnit): IDateManager; subtract(value: number, unit: TimeUnit): IDateManager; - - // Update: Parameter sekarang support DateService instance juga (untuk DX lebih baik) isBefore(date: DateInput | DateService): boolean; isAfter(date: DateInput | DateService): boolean; isSame(date: DateInput | DateService, unit?: TimeUnit): boolean; diff(date: DateInput | DateService, unit: TimeUnit, precise?: boolean): number; - - toISOString(): string; - toDate(): Date; + diffCalendarDay(date: DateInput | DateService): number; startOf(unit: TimeUnit): IDateManager; endOf(unit: TimeUnit): IDateManager; + toISOString(): string; + toDate(): Date; } +/* ------------------------------------------------------------------ + * DateService + * ------------------------------------------------------------------ */ + +/** + * Application-wide date abstraction. + * + * All instances are automatically normalized + * to a single global timezone. + */ export class DateService implements IDateManager { - // Kita expose _date sebagai public readonly atau getter jika perlu akses raw dayjs - // Tapi untuk strict encapsulation, keep private. private readonly _date: Dayjs; - constructor(date?: DateInput | DateService) { - // FIX: Handle jika inputnya adalah instance dari DateService lain - if (date instanceof DateService) { - this._date = date.getRaw(); - } else { - this._date = dayjs(date); - } + /** + * Global default timezone. + * Used by all DateService instances. + */ + private static _defaultTimezone: string = dayjs.tz.guess(); - if (!this._date.isValid()) { - console.warn(`[DateService] Invalid date: ${date}. Fallback to now.`); - this._date = dayjs(); + /* ---------------------------------------------------------------- + * Global Configuration + * ---------------------------------------------------------------- */ + + /** + * Set the global timezone for the application. + * + * @example + * DateService.setGlobalConfig('Asia/Jakarta'); + */ + static setGlobalConfig(timezone: string): void { + try { + dayjs().tz(timezone); + DateService._defaultTimezone = timezone; + } catch { + console.error(`[DateService] Invalid timezone "${timezone}". Using previous value.`); } } - // Helper internal untuk mengambil raw object (diperlukan untuk interaksi antar instance) - public getRaw(): Dayjs { - return this._date; + /** + * Get the currently active global timezone. + */ + static getGlobalTimezone(): string { + return DateService._defaultTimezone; } + /** + * Create a DateService instance representing the current moment. + */ static now(): DateService { return new DateService(); } - // --- Implementation Methods --- + /* ---------------------------------------------------------------- + * Constructor + * ---------------------------------------------------------------- */ - format(formatString: string = 'YYYY-MM-DD'): string { - return this._date.format(formatString); + constructor(date?: DateInput | DateService) { + if (date instanceof DateService) { + this._date = date.getRaw().tz(DateService._defaultTimezone); + } else { + this._date = dayjs(date).tz(DateService._defaultTimezone); + } + + if (!this._date.isValid()) { + console.warn('[DateService] Invalid date input. Falling back to now().'); + this._date = dayjs().tz(DateService._defaultTimezone); + } } + /* ---------------------------------------------------------------- + * Internal Utilities + * ---------------------------------------------------------------- */ + + /** + * Access the internal Day.js instance. + * Intended for advanced usage only. + */ + getRaw(): Dayjs { + return this._date; + } + + /** + * Normalize input into Day.js using the global timezone. + */ + private toDayjs(date: DateInput | DateService): Dayjs { + return date instanceof DateService ? date.getRaw() : dayjs(date).tz(DateService._defaultTimezone); + } + + /* ---------------------------------------------------------------- + * Formatting & Conversion + * ---------------------------------------------------------------- */ + + format(format: string = 'YYYY-MM-DD'): string { + return this._date.format(format); + } + + toISOString(): string { + // Always returns UTC (ISO 8601 compliant) + return this._date.toISOString(); + } + + toDate(): Date { + return this._date.toDate(); + } + + /* ---------------------------------------------------------------- + * Manipulation + * ---------------------------------------------------------------- */ + add(value: number, unit: TimeUnit): DateService { return new DateService(this._date.add(value, unit as ManipulateType)); } @@ -67,40 +199,6 @@ export class DateService implements IDateManager { return new DateService(this._date.subtract(value, unit as ManipulateType)); } - // Helper private untuk normalisasi input (menangani DateService vs DateInput biasa) - private _toDayjs(date: DateInput | DateService): Dayjs { - if (date instanceof DateService) { - return date.getRaw(); - } - return dayjs(date); - } - - isBefore(date: DateInput | DateService): boolean { - return this._date.isBefore(this._toDayjs(date)); - } - - isAfter(date: DateInput | DateService): boolean { - return this._date.isAfter(this._toDayjs(date)); - } - - isSame(date: DateInput | DateService, unit?: TimeUnit): boolean { - return this._date.isSame(this._toDayjs(date), unit as OpUnitType); - } - - diff(date: DateInput | DateService, unit: TimeUnit, precise: boolean = false): number { - return this._date.diff(this._toDayjs(date), unit as OpUnitType, precise); - } - - /** - * Menghitung selisih HARI KALENDER. - * Mengabaikan jam/menit, murni membandingkan tanggal. - */ - diffCalendarDay(date: DateInput | DateService): number { - const target = this._toDayjs(date); - // Reset keduanya ke jam 00:00:00 sebelum diff - return this._date.startOf('day').diff(target.startOf('day'), 'day'); - } - startOf(unit: TimeUnit): DateService { return new DateService(this._date.startOf(unit as OpUnitType)); } @@ -109,32 +207,90 @@ export class DateService implements IDateManager { return new DateService(this._date.endOf(unit as OpUnitType)); } - toISOString(): string { - return this._date.toISOString(); + /* ---------------------------------------------------------------- + * Comparison + * ---------------------------------------------------------------- */ + + isBefore(date: DateInput | DateService): boolean { + return this._date.isBefore(this.toDayjs(date)); } - toDate(): Date { - return this._date.toDate(); + isAfter(date: DateInput | DateService): boolean { + return this._date.isAfter(this.toDayjs(date)); } + isSame(date: DateInput | DateService, unit?: TimeUnit): boolean { + return this._date.isSame(this.toDayjs(date), unit as OpUnitType); + } + + diff(date: DateInput | DateService, unit: TimeUnit, precise: boolean = false): number { + return this._date.diff(this.toDayjs(date), unit as OpUnitType, precise); + } + + /** + * Calendar-day difference ignoring time components. + */ + diffCalendarDay(date: DateInput | DateService): number { + const target = this.toDayjs(date); + return this._date.startOf('day').diff(target.startOf('day'), 'day'); + } + + /* ---------------------------------------------------------------- + * Epoch Helpers + * ---------------------------------------------------------------- */ + get timestamp(): number { return this._date.valueOf(); } - /* Mengembalikan Epoch dalam Milliseconds (13 digit). - * Contoh: 1704067200000 - * Gunakan ini untuk kalkulasi di Frontend JS/TS. - */ get epochMillis(): number { return this._date.valueOf(); } - /** - * Mengembalikan Epoch dalam Seconds (10 digit). - * Contoh: 1704067200 - * Gunakan ini untuk kirim ke Backend (PHP, Golang, Python, dll) atau JWT. - */ get epochSeconds(): number { return this._date.unix(); } + + /* ---------------------------------------------------------------- + * Timezone Utilities + * ---------------------------------------------------------------- */ + + /** + * Retrieve supported IANA timezones from the runtime environment. + */ + static getSupportedTimezones(): string[] { + if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') { + try { + return Intl.supportedValuesOf('timeZone'); + } catch { + console.warn('[DateService] Failed to retrieve timezones via Intl.'); + } + } + + return [ + 'UTC', + 'Asia/Jakarta', + 'Asia/Makassar', + 'Asia/Jayapura', + 'Asia/Singapore', + 'Asia/Tokyo', + 'Australia/Sydney', + 'Europe/London', + 'Europe/Paris', + 'America/New_York', + 'America/Los_Angeles', + ]; + } + + /** + * Human-readable timezone abbreviation. + * + * Priority: + * 1. Indonesian mapping (WIB / WITA / WIT) + * 2. Day.js dynamic abbreviation (DST-safe) + */ + get timezoneAbbr(): string { + const tz = DateService._defaultTimezone; + return INDONESIA_TZ_MAP[tz] ?? this._date.format('z'); + } }