diff --git a/.vscode/settings.json b/.vscode/settings.json
index 44a73ec..18aa648 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,5 +3,6 @@
{
"mode": "auto"
}
- ]
+ ],
+ "cSpell.words": ["Ujung", "Pandang", "WITA"]
}
diff --git a/apps/web/src/apps/modules/index.tsx b/apps/web/src/apps/modules/index.tsx
index 7c14b18..da0b2d7 100644
--- a/apps/web/src/apps/modules/index.tsx
+++ b/apps/web/src/apps/modules/index.tsx
@@ -1,9 +1,10 @@
+import { DateServiceComponent } from '@repo/ui';
import { Route, Routes } from 'react-router-dom';
export default function AppModule() {
return (
- 1} />
+ } />
);
}
diff --git a/packages/ui/src/components/date-example.tsx b/packages/ui/src/components/date-example.tsx
index 42f8fa9..f9a97a1 100644
--- a/packages/ui/src/components/date-example.tsx
+++ b/packages/ui/src/components/date-example.tsx
@@ -122,6 +122,9 @@ export const DateServiceComponent: React.FC = () => {
({currentTime.format('Z')})
+
+ {currentTime.timezoneWithOffset}
+
{currentTime.format('dddd, DD MMMM YYYY')}
diff --git a/packages/utils/src/date-service/date-service.test.ts b/packages/utils/src/date-service/date-service.test.ts
new file mode 100644
index 0000000..ba51ca6
--- /dev/null
+++ b/packages/utils/src/date-service/date-service.test.ts
@@ -0,0 +1,227 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { DateService } from './date-service';
+
+describe('DateService', () => {
+ // Setup standard environment before each test
+ beforeEach(() => {
+ // 1. Reset Global Timezone to UTC for deterministic results across environments
+ DateService.setGlobalConfig('UTC');
+
+ // 2. Mock console warn/error to keep the terminal clean during error handling tests
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ // Clean up all mocks and restore real timers after each test
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ describe('Initialization', () => {
+ it('should create an instance with the current time (now) using static method', () => {
+ const date = DateService.now();
+ expect(date).toBeInstanceOf(DateService);
+ expect(date.toDate()).toBeInstanceOf(Date);
+ });
+
+ it('should create an instance from a specific ISO string', () => {
+ // Use full ISO format (.000Z) for precision
+ const input = '2025-01-01T10:00:00.000Z';
+ const date = new DateService(input);
+ expect(date.toISOString()).toBe(input);
+ });
+
+ it('should create a clone from another DateService instance', () => {
+ const original = new DateService('2025-01-01T00:00:00.000Z');
+ const clone = new DateService(original);
+
+ expect(clone.toISOString()).toBe(original.toISOString());
+ expect(clone).not.toBe(original); // Ensure references are different (Memory Address check)
+ });
+
+ it('should handle invalid input by falling back to current time (Graceful Fallback)', () => {
+ // Freeze system time for precise assertions
+ const fixedTime = new Date('2025-01-01T12:00:00.000Z');
+ vi.useFakeTimers();
+ vi.setSystemTime(fixedTime);
+
+ const invalidDate = new DateService('invalid-date-string-xyz');
+
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid date input'));
+ expect(invalidDate.toISOString()).toBe(fixedTime.toISOString());
+ });
+ });
+
+ describe('Global Configuration', () => {
+ it('should update global timezone correctly', () => {
+ DateService.setGlobalConfig('Asia/Jakarta');
+ expect(DateService.getGlobalTimezone()).toBe('Asia/Jakarta');
+ });
+
+ it('should handle invalid timezone gracefully', () => {
+ const initialTz = DateService.getGlobalTimezone();
+ DateService.setGlobalConfig('Mars/Alien_City');
+
+ expect(console.error).toHaveBeenCalled();
+ expect(DateService.getGlobalTimezone()).toBe(initialTz);
+ });
+
+ it('should apply timezone to new instances', () => {
+ const utcString = '2025-01-01T00:00:00Z';
+
+ // Test UTC
+ DateService.setGlobalConfig('UTC');
+ const dateUtc = new DateService(utcString);
+ expect(dateUtc.format('HH:mm')).toBe('00:00');
+
+ // Test Jakarta (UTC+7)
+ DateService.setGlobalConfig('Asia/Jakarta');
+ const dateJkt = new DateService(utcString);
+ expect(dateJkt.format('HH:mm')).toBe('07:00');
+ });
+ });
+
+ describe('Immutability & Manipulation', () => {
+ // This verifies that .clone() is working effectively
+ it('should be immutable on "add"', () => {
+ const start = new DateService('2025-01-01T00:00:00.000Z');
+ const nextDay = start.add(1, 'day');
+
+ expect(start.format('DD')).toBe('01'); // Original instance MUST NOT change
+ expect(nextDay.format('DD')).toBe('02'); // New instance should reflect the change
+ expect(start).not.toBe(nextDay); // Ensure they are different objects
+ });
+
+ it('should be immutable on "subtract"', () => {
+ const start = new DateService('2025-01-02T00:00:00.000Z');
+ const prevDay = start.subtract(1, 'day');
+
+ expect(start.format('DD')).toBe('02'); // Original instance MUST NOT change
+ expect(prevDay.format('DD')).toBe('01');
+ expect(start).not.toBe(prevDay);
+ });
+
+ it('should be immutable on "startOf"', () => {
+ const date = new DateService('2025-01-15T12:00:00.000Z');
+ const startOfMonth = date.startOf('month');
+
+ expect(date.format('DD')).toBe('15'); // Original remains 15th
+ expect(startOfMonth.format('DD')).toBe('01'); // New instance is 1st
+ expect(startOfMonth).not.toBe(date);
+ });
+
+ it('should be immutable on "endOf"', () => {
+ const date = new DateService('2025-01-01T00:00:00.000Z');
+ const endOfDay = date.endOf('day');
+
+ // Format HH:mm:ss depends on global timezone (currently UTC)
+ expect(endOfDay.format('HH:mm:ss')).toBe('23:59:59');
+ expect(endOfDay).not.toBe(date);
+ });
+ });
+
+ describe('Formatting & Comparison', () => {
+ it('should format date string correctly', () => {
+ const date = new DateService('2025-12-25T00:00:00.000Z');
+ expect(date.format('DD/MM/YYYY')).toBe('25/12/2025');
+ });
+
+ it('should return strict ISO 8601 string (always UTC)', () => {
+ DateService.setGlobalConfig('Asia/Jakarta');
+ // Input Jakarta local time 7 AM = 0 AM UTC
+ const date = new DateService('2025-01-01T07:00:00+07:00');
+ expect(date.toISOString()).toBe('2025-01-01T00:00:00.000Z');
+ });
+
+ it('should compare dates correctly', () => {
+ const d1 = new DateService('2025-01-01T00:00:00.000Z');
+ const d2 = new DateService('2025-01-02T00:00:00.000Z');
+
+ expect(d1.isBefore(d2)).toBe(true);
+ expect(d2.isAfter(d1)).toBe(true);
+ expect(d1.isSame(d1)).toBe(true);
+ });
+
+ it('should calculate precise diff', () => {
+ const d1 = new DateService('2025-01-01T00:00:00.000Z');
+ const d2 = new DateService('2025-01-03T00:00:00.000Z');
+ expect(d2.diff(d1, 'day')).toBe(2);
+ });
+
+ it('should calculate calendar day diff (ignoring time)', () => {
+ // Case: 23:00 vs 01:00 the next day
+ // Technically only 2 hours difference (0 full days), but 1 calendar day difference.
+ const d1 = new DateService('2025-01-01T23:00:00.000Z');
+ const d2 = new DateService('2025-01-02T01:00:00.000Z');
+
+ expect(d2.diff(d1, 'day')).toBe(0); // Standard diff (less than 24h)
+ expect(d2.diffCalendarDay(d1)).toBe(1); // Calendar diff
+ });
+ });
+
+ describe('Timezone Utilities (Indonesian & Offset)', () => {
+ it('should return custom mapping for Indonesia (WIB)', () => {
+ DateService.setGlobalConfig('Asia/Jakarta');
+ const date = new DateService();
+ expect(date.timezoneAbbr).toBe('WIB');
+ });
+
+ it('should return custom mapping for Indonesia (WITA)', () => {
+ DateService.setGlobalConfig('Asia/Makassar');
+ const date = new DateService();
+ expect(date.timezoneAbbr).toBe('WITA');
+ });
+
+ it('should return custom mapping for Indonesia (WIT)', () => {
+ DateService.setGlobalConfig('Asia/Jayapura');
+ const date = new DateService();
+ expect(date.timezoneAbbr).toBe('WIT');
+ });
+
+ it('should fallback to standard abbr for other zones', () => {
+ DateService.setGlobalConfig('UTC');
+ const date = new DateService();
+ expect(date.timezoneAbbr).toBe('UTC');
+ });
+
+ // NEW FEATURE TEST: timezoneWithOffset
+ it('should return combined timezone abbreviation and offset', () => {
+ const isoInput = '2025-01-01T12:00:00Z'; // Input in UTC
+
+ // Case 1: Jakarta
+ DateService.setGlobalConfig('Asia/Jakarta');
+ const dateJkt = new DateService(isoInput);
+ // Jakarta = UTC+7
+ expect(dateJkt.timezoneWithOffset).toBe('WIB (+07:00)');
+
+ // Case 2: Makassar
+ DateService.setGlobalConfig('Asia/Makassar');
+ const dateMks = new DateService(isoInput);
+ // Makassar = UTC+8
+ expect(dateMks.timezoneWithOffset).toBe('WITA (+08:00)');
+
+ // Case 3: UTC
+ DateService.setGlobalConfig('UTC');
+ const dateUtc = new DateService(isoInput);
+ expect(dateUtc.timezoneWithOffset).toBe('UTC (+00:00)');
+ });
+ });
+
+ describe('Epoch & Helpers', () => {
+ it('should return correct epoch values', () => {
+ const input = 1704067200000; // 2024-01-01 00:00:00 UTC
+ const date = new DateService(input);
+
+ expect(date.timestamp).toBe(input);
+ expect(date.epochMillis).toBe(input);
+ expect(date.epochSeconds).toBe(1704067200);
+ });
+
+ it('should return supported timezones list', () => {
+ const timezones = DateService.getSupportedTimezones();
+ expect(Array.isArray(timezones)).toBe(true);
+ expect(timezones).toContain('Asia/Jakarta');
+ });
+ });
+});
diff --git a/packages/utils/src/date-service/index.ts b/packages/utils/src/date-service/date-service.ts
similarity index 91%
rename from packages/utils/src/date-service/index.ts
rename to packages/utils/src/date-service/date-service.ts
index f385b3b..8ec8c58 100644
--- a/packages/utils/src/date-service/index.ts
+++ b/packages/utils/src/date-service/date-service.ts
@@ -192,19 +192,19 @@ export class DateService implements IDateManager {
* ---------------------------------------------------------------- */
add(value: number, unit: TimeUnit): DateService {
- return new DateService(this._date.add(value, unit as ManipulateType));
+ return new DateService(this._date.clone().add(value, unit as ManipulateType));
}
subtract(value: number, unit: TimeUnit): DateService {
- return new DateService(this._date.subtract(value, unit as ManipulateType));
+ return new DateService(this._date.clone().subtract(value, unit as ManipulateType));
}
startOf(unit: TimeUnit): DateService {
- return new DateService(this._date.startOf(unit as OpUnitType));
+ return new DateService(this._date.clone().startOf(unit as OpUnitType));
}
endOf(unit: TimeUnit): DateService {
- return new DateService(this._date.endOf(unit as OpUnitType));
+ return new DateService(this._date.clone().endOf(unit as OpUnitType));
}
/* ----------------------------------------------------------------
@@ -293,4 +293,22 @@ export class DateService implements IDateManager {
const tz = DateService._defaultTimezone;
return INDONESIA_TZ_MAP[tz] ?? this._date.format('z');
}
+
+ /* ----------------------------------------------------------------
+ * Timezone Utilities
+ * ---------------------------------------------------------------- */
+
+ // ... (method getSupportedTimezones dan get timezoneAbbr yang sudah ada)
+
+ /**
+ * Returns timezone abbreviation combined with GMT offset.
+ * Useful for UI labels.
+ *
+ * @example
+ * "WIB (+07:00)"
+ * "UTC (+00:00)"
+ */
+ get timezoneWithOffset(): string {
+ return `${this.timezoneAbbr} (${this._date.format('Z')})`;
+ }
}
diff --git a/packages/utils/src/encryption/encryption-service.test.ts b/packages/utils/src/encryption/encryption-service.test.ts
new file mode 100644
index 0000000..eeff07f
--- /dev/null
+++ b/packages/utils/src/encryption/encryption-service.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { EncryptionService } from './encryption-service';
+import { AES } from 'crypto-js';
+
+// 1. Mock the key module to ensure test consistency
+vi.mock('./encryption-key', () => ({
+ ENC_STORAGE_KEY: 'default-test-key-123',
+}));
+
+describe('EncryptionService', () => {
+ const TEST_KEY = 'secret-key-xyz';
+ let service: EncryptionService;
+
+ beforeEach(() => {
+ // Reset the Singleton instance to ensure clean state for every test
+ (EncryptionService as any).instance = undefined;
+ service = new EncryptionService(TEST_KEY);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('Constructor', () => {
+ it('should create an instance with the provided key', () => {
+ const instance = new EncryptionService('custom-key');
+ expect(instance).toBeInstanceOf(EncryptionService);
+ });
+
+ it('should use the default ENC_STORAGE_KEY if no key is provided', () => {
+ const instance = new EncryptionService();
+ // Verify functionality with default key
+ const data = 'test-default';
+ const encrypted = instance.encrypt(data);
+ expect(instance.decrypt(encrypted)).toBe(data);
+ });
+
+ it('should throw an Error if the provided key is empty', () => {
+ expect(() => new EncryptionService('')).toThrow('[EncryptionService] Encryption key is missing/empty.');
+ });
+ });
+
+ describe('encrypt()', () => {
+ it('should return an encrypted string different from the plain text', () => {
+ const plainText = 'Hello World';
+ const encrypted = service.encrypt(plainText);
+
+ expect(encrypted).not.toBe(plainText);
+ expect(encrypted).not.toBe('');
+ expect(typeof encrypted).toBe('string');
+ });
+
+ it('should return an empty string if input is empty', () => {
+ expect(service.encrypt('')).toBe('');
+ });
+ });
+
+ describe('decrypt()', () => {
+ it('should decrypt data back to the original text correctly', () => {
+ const plainText = 'Secret Data 123';
+ const encrypted = service.encrypt(plainText);
+ const decrypted = service.decrypt(encrypted);
+
+ expect(decrypted).toBe(plainText);
+ });
+
+ it('should return an empty string if input is empty', () => {
+ expect(service.decrypt('')).toBe('');
+ });
+
+ it('should return an empty string if decrypted with the WRONG key', () => {
+ const plainText = 'Sensitive Data';
+
+ // Encrypt with Service A (TEST_KEY)
+ const encrypted = service.encrypt(plainText);
+
+ // Decrypt with Service B (WRONG_KEY)
+ const wrongService = new EncryptionService('wrong-key-999');
+ const result = wrongService.decrypt(encrypted);
+
+ expect(result).toBe('');
+ });
+
+ it('should return an empty string if input is malformed (Graceful Fail)', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const malformedData = 'invalid-base64-string-@@@';
+ const result = service.decrypt(malformedData);
+
+ expect(result).toBe('');
+ // NOTE: crypto-js handles garbage input gracefully without throwing,
+ // so console.error is NOT called here.
+ });
+
+ it('should catch error and log it when decryption throws an exception (Critical Fail)', () => {
+ /**
+ * Spy on console.error to suppress and track error logging during the test.
+ * Mocked to prevent error messages from appearing in test output.
+ */
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ // FORCE an error by mocking AES.decrypt implementation
+ vi.spyOn(AES, 'decrypt').mockImplementationOnce(() => {
+ throw new Error('Simulated Crypto Failure');
+ });
+
+ const result = service.decrypt('any-data');
+
+ expect(result).toBe('');
+ // Now we expect the catch block to be executed
+ expect(consoleSpy).toHaveBeenCalledWith('[EncryptionService] Decryption failed:', expect.any(Error));
+ });
+ });
+
+ describe('getInstance() (Singleton)', () => {
+ it('should return the same instance reference', () => {
+ const instance1 = EncryptionService.getInstance();
+ const instance2 = EncryptionService.getInstance();
+
+ expect(instance1).toBe(instance2);
+ });
+ });
+});
diff --git a/packages/utils/src/encryption/index.ts b/packages/utils/src/encryption/encryption-service.ts
similarity index 68%
rename from packages/utils/src/encryption/index.ts
rename to packages/utils/src/encryption/encryption-service.ts
index af420bd..32f798d 100644
--- a/packages/utils/src/encryption/index.ts
+++ b/packages/utils/src/encryption/encryption-service.ts
@@ -2,7 +2,7 @@ import { AES, enc } from 'crypto-js';
import { ENC_STORAGE_KEY } from './encryption-key';
/**
- * Interface contract supaya method konsisten
+ * Interface defining encryption service methods.
*/
interface IEncryptionService {
encrypt(data: string): string;
@@ -13,7 +13,8 @@ export class EncryptionService implements IEncryptionService {
private readonly _key: string;
/**
- * @param key (Optional) Jika tidak diisi, otomatis pakai ENC_STORAGE_KEY
+ * @param key - Encryption key used for encrypting and decrypting data.
+ * If not provided, defaults to ENC_STORAGE_KEY.
*/
constructor(key: string = ENC_STORAGE_KEY) {
if (!key) {
@@ -23,7 +24,8 @@ export class EncryptionService implements IEncryptionService {
}
/**
- * Mengenkripsi string plain text.
+ * Encrypts the given data string.
+ * Returns an empty string '' if input data is empty.
*/
public encrypt(data: string): string {
if (!data) {
@@ -33,8 +35,8 @@ export class EncryptionService implements IEncryptionService {
}
/**
- * Mendekripsi string terenkripsi.
- * Mengembalikan string kosong '' jika gagal decrypt atau format salah.
+ * Decrypts the given encrypted data string.
+ * Returns an empty string '' if input is empty or decryption fails.
*/
public decrypt(encryptedData: string): string {
if (!encryptedData) {
@@ -45,7 +47,7 @@ export class EncryptionService implements IEncryptionService {
const bytes = AES.decrypt(encryptedData, this._key);
const originalText = bytes.toString(enc.Utf8);
- // Validasi tambahan: jika hasil decrypt kosong, berarti key salah atau data corrupt
+ // If decryption yields an empty string, consider it a failure
if (!originalText) {
return '';
}
@@ -57,8 +59,12 @@ export class EncryptionService implements IEncryptionService {
}
}
- // --- Static Helper (Singleton Pattern sederhana) ---
- // Supaya tidak perlu 'new EncryptionService()' berulang kali
+ /**
+ * Get the singleton instance of EncryptionService.
+ * Uses the default ENC_STORAGE_KEY.
+ * @return EncryptionService instance
+ */
+
private static instance: EncryptionService;
public static getInstance(): EncryptionService {
diff --git a/packages/utils/src/example/index.ts b/packages/utils/src/example/index.ts
deleted file mode 100644
index 6a3cfe8..0000000
--- a/packages/utils/src/example/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function utilsExample() {
- return 'This is an example utility function.';
-}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index d485af2..73ff3f0 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -1,3 +1,4 @@
-export * from './encryption';
-export * from './example';
-export * from './date-service';
+export * from './encryption/encryption-key';
+export * from './encryption/encryption-service';
+
+export * from './date-service/date-service';
diff --git a/packages/utils/src/testing-example/add.ts b/packages/utils/src/testing-example/add.ts
deleted file mode 100644
index bc81dd5..0000000
--- a/packages/utils/src/testing-example/add.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const add = (a: number, b: number) => a + b;
diff --git a/packages/utils/src/testing-example/subtract.ts b/packages/utils/src/testing-example/subtract.ts
deleted file mode 100644
index 03240ac..0000000
--- a/packages/utils/src/testing-example/subtract.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const subtract = (a: number, b: number) => a - b;
diff --git a/packages/utils/src/testing-example/utils-testing-example.test.ts b/packages/utils/src/testing-example/utils-testing-example.test.ts
deleted file mode 100644
index 0f223f0..0000000
--- a/packages/utils/src/testing-example/utils-testing-example.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { expect, test } from 'vitest';
-import { add } from './add';
-import { subtract } from './subtract';
-
-test('adds 1 + 2 to equal 3', () => {
- expect(add(1, 2)).toBe(3);
-});
-
-test('subtracts 2 - 1 to equal 1', () => {
- expect(subtract(2, 1)).toBe(1);
-});