diff --git a/package.json b/package.json index 08afe22..64508cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wikytam/helpers", - "version": "1.0.4", + "version": "1.1.0", "description": "TypeScript utility library ported from yii\\i18n\\Formatter - zero dependencies, built-in Intl APIs only", "type": "module", "main": "./dist/index.cjs", diff --git a/src/__tests__/formatter-date.test.ts b/src/__tests__/formatter-date.test.ts index 0f6730c..8578b94 100644 --- a/src/__tests__/formatter-date.test.ts +++ b/src/__tests__/formatter-date.test.ts @@ -216,7 +216,6 @@ describe("Formatter - Ngày tháng & Thời gian", () => { it("format ngày theo locale tiếng Việt", () => { const fVi = new Formatter({ locale: "vi-VN", timeZone: "UTC" }) const result = fVi.asDate(testDate, "long") - // Kết quả chứa tháng tiếng Việt expect(result).toContain("2024") }) @@ -226,4 +225,141 @@ describe("Formatter - Ngày tháng & Thời gian", () => { expect(result).toContain("2024") }) }) + + describe("defaultTimeZone reinterpret (Z-suffix)", () => { + // Simulates: backend returns "2024-03-15T14:30:45.000Z" but the timestamp + // is actually Asia/Ho_Chi_Minh (UTC+7). Without reinterpret, new Date() + // would treat it as UTC, shifting the time by -7 hours. + + it("reinterprets Z-suffix as Asia/Ho_Chi_Minh when defaultTimeZone is set", () => { + const fVn = new Formatter({ + locale: "en-US", + timeZone: "Asia/Ho_Chi_Minh", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + // Backend says 14:30:45Z but means 14:30:45 VN time (= 07:30:45 UTC) + const result = fVn.asTime("2024-03-15T14:30:45.000Z", "short") + // Display in VN timezone should show 14:30, not 21:30 + expect(result).toContain("2:30") + }) + + it("does NOT reinterpret when defaultTimeZone is UTC (default)", () => { + const fUtc = new Formatter({ + locale: "en-US", + timeZone: "UTC", + defaultTimeZone: "UTC", + }) + const result = fUtc.asTime("2024-03-15T14:30:45.000Z", "short") + expect(result).toContain("2:30") + }) + + it("asDate reinterprets correctly for Asia/Ho_Chi_Minh", () => { + const fVn = new Formatter({ + locale: "en-US", + timeZone: "Asia/Ho_Chi_Minh", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + // "2024-03-15T23:30:00.000Z" as VN time = 23:30 VN, still March 15 + // Without reinterpret it would be March 16 at 06:30 VN time + const result = fVn.asDate("2024-03-15T23:30:00.000Z") + expect(result).toContain("Mar") + expect(result).toContain("15") + }) + + it("asDatetime reinterprets correctly", () => { + const fVn = new Formatter({ + locale: "en-US", + timeZone: "Asia/Ho_Chi_Minh", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + const result = fVn.asDatetime("2024-03-15T14:30:45.000Z") + expect(result).toContain("2:30:45") + expect(result).toContain("Mar") + expect(result).toContain("15") + }) + + it("asTimestamp reinterprets Z-suffix with defaultTimeZone", () => { + const fVn = new Formatter({ + locale: "en-US", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + // "2024-03-15T14:30:45.000Z" reinterpreted as +07:00 + // = 2024-03-15T14:30:45+07:00 = 2024-03-15T07:30:45Z + const result = fVn.asTimestamp("2024-03-15T14:30:45.000Z") + const expected = Math.floor( + new Date("2024-03-15T07:30:45.000Z").getTime() / 1000, + ) + expect(result).toBe(String(expected)) + }) + + it("asRelativeTime reinterprets both value and referenceTime", () => { + const fVn = new Formatter({ + locale: "en-US", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + // Both timestamps treated as VN time + const result = fVn.asRelativeTime( + "2024-03-15T16:30:00.000Z", + "2024-03-15T14:30:00.000Z", + ) + expect(result).toContain("2 hours") + }) + + it("does not affect strings without Z suffix", () => { + const fVn = new Formatter({ + locale: "en-US", + timeZone: "Asia/Ho_Chi_Minh", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + // String with explicit offset is left as-is + const result = fVn.asTime("2024-03-15T14:30:45+07:00", "short") + expect(result).toContain("2:30") + }) + + it("does not affect Date objects", () => { + const fVn = new Formatter({ + locale: "en-US", + timeZone: "UTC", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + const date = new Date("2024-03-15T14:30:45.000Z") + // Date objects bypass reinterpretation + const result = fVn.asTime(date, "short") + expect(result).toContain("2:30") + }) + + it("does not affect UNIX timestamps (numbers)", () => { + const fVn = new Formatter({ + locale: "en-US", + timeZone: "UTC", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + // UNIX timestamps are absolute, no reinterpretation needed + const result = fVn.asTimestamp(1710513045) + expect(result).toBe("1710513045") + }) + + it("works with Asia/Tokyo (UTC+9)", () => { + const fJp = new Formatter({ + locale: "en-US", + timeZone: "Asia/Tokyo", + defaultTimeZone: "Asia/Tokyo", + }) + // "2024-03-15T14:30:45Z" reinterpreted as JST + // = 14:30:45+09:00 = 05:30:45 UTC -> display in JST = 14:30 + const result = fJp.asTime("2024-03-15T14:30:45.000Z", "short") + expect(result).toContain("2:30") + }) + + it("works with America/New_York (UTC-4/-5)", () => { + const fNy = new Formatter({ + locale: "en-US", + timeZone: "America/New_York", + defaultTimeZone: "America/New_York", + }) + // "2024-03-15T14:30:45Z" reinterpreted as ET (DST = UTC-4 in March 2024) + const result = fNy.asTime("2024-03-15T14:30:45.000Z", "short") + expect(result).toContain("2:30") + }) + }) }) diff --git a/src/__tests__/formatter-global.test.ts b/src/__tests__/formatter-global.test.ts index 6fc9647..9f4eca7 100644 --- a/src/__tests__/formatter-global.test.ts +++ b/src/__tests__/formatter-global.test.ts @@ -5,7 +5,7 @@ import { formatPercent, formatter, } from "../global.js" -import { parseDate } from "../utils.js" +import { getIANAOffset, parseDate } from "../utils.js" describe("Global formatter singleton", () => { it("works with default config out of the box", () => { @@ -85,6 +85,68 @@ describe("parseDate()", () => { const result = parseDate("2024-03-15") expect(result.getFullYear()).toBe(2024) }) + + it("reinterprets Z-suffix as Asia/Ho_Chi_Minh when defaultTimeZone is set", () => { + // "14:30:45Z" reinterpreted as +07:00 -> 14:30:45+07:00 = 07:30:45 UTC + const result = parseDate("2024-03-15T14:30:45.000Z", "Asia/Ho_Chi_Minh") + expect(result.toISOString()).toBe("2024-03-15T07:30:45.000Z") + }) + + it("does not reinterpret when defaultTimeZone is UTC", () => { + const result = parseDate("2024-03-15T14:30:45.000Z", "UTC") + expect(result.toISOString()).toBe("2024-03-15T14:30:45.000Z") + }) + + it("does not reinterpret non-Z strings even with defaultTimeZone", () => { + const result = parseDate("2024-03-15T14:30:45+07:00", "Asia/Ho_Chi_Minh") + expect(result.toISOString()).toBe("2024-03-15T07:30:45.000Z") + }) + + it("reinterprets with Asia/Tokyo (UTC+9)", () => { + // "14:30:45Z" reinterpreted as +09:00 -> 14:30:45+09:00 = 05:30:45 UTC + const result = parseDate("2024-03-15T14:30:45.000Z", "Asia/Tokyo") + expect(result.toISOString()).toBe("2024-03-15T05:30:45.000Z") + }) + + it("still appends Z for timezone-naive strings even with defaultTimeZone", () => { + // No Z suffix and no offset -> treated as UTC regardless + const result = parseDate("2024-03-15T14:30:45", "Asia/Ho_Chi_Minh") + expect(result.toISOString()).toBe("2024-03-15T14:30:45.000Z") + }) +}) + +// ─── getIANAOffset() ────────────────────────────────────────────── + +describe("getIANAOffset()", () => { + const refDate = new Date("2024-06-15T12:00:00Z") + + it("returns +07:00 for Asia/Ho_Chi_Minh", () => { + expect(getIANAOffset("Asia/Ho_Chi_Minh", refDate)).toBe("+07:00") + }) + + it("returns +09:00 for Asia/Tokyo", () => { + expect(getIANAOffset("Asia/Tokyo", refDate)).toBe("+09:00") + }) + + it("returns +00:00 for UTC", () => { + expect(getIANAOffset("UTC", refDate)).toBe("+00:00") + }) + + it("returns correct offset for negative timezone", () => { + // America/New_York is -04:00 in summer (EDT) + const summerDate = new Date("2024-06-15T12:00:00Z") + expect(getIANAOffset("America/New_York", summerDate)).toBe("-04:00") + }) + + it("handles DST transitions", () => { + // America/New_York: EST = -05:00 in winter + const winterDate = new Date("2024-01-15T12:00:00Z") + expect(getIANAOffset("America/New_York", winterDate)).toBe("-05:00") + }) + + it("handles half-hour offset (Asia/Kolkata = +05:30)", () => { + expect(getIANAOffset("Asia/Kolkata", refDate)).toBe("+05:30") + }) }) // ─── formatDate() ───────────────────────────────────────────────── @@ -128,6 +190,18 @@ describe("formatDate()", () => { const result = formatDate(new Date("2024-03-15T14:30:00Z")) expect(result).toContain("2024") }) + + it("uses global formatter defaultTimeZone to reinterpret Z-suffix", () => { + configureFormatter({ + locale: "en-US", + timeZone: "Asia/Ho_Chi_Minh", + defaultTimeZone: "Asia/Ho_Chi_Minh", + }) + // "23:30Z" reinterpreted as VN time -> still March 15 + const result = formatDate("2024-03-15T23:30:00.000Z") + expect(result).toContain("Mar") + expect(result).toContain("15") + }) }) // ─── formatPercent() ────────────────────────────────────────────── diff --git a/src/formatter.ts b/src/formatter.ts index 852606d..c02ad20 100644 --- a/src/formatter.ts +++ b/src/formatter.ts @@ -472,7 +472,7 @@ export class Formatter { format?: string | Intl.DateTimeFormatOptions, ): string { if (isBlank(value)) return this.nullDisplay - const date = normalizeDate(value) + const date = normalizeDate(value, this.defaultTimeZone) const resolved = resolveDateFormat( format ?? this.dateFormat, "medium", @@ -491,7 +491,7 @@ export class Formatter { format?: string | Intl.DateTimeFormatOptions, ): string { if (isBlank(value)) return this.nullDisplay - const date = normalizeDate(value) + const date = normalizeDate(value, this.defaultTimeZone) const resolved = resolveDateFormat( format ?? this.timeFormat, "medium", @@ -510,7 +510,7 @@ export class Formatter { format?: string | Intl.DateTimeFormatOptions, ): string { if (isBlank(value)) return this.nullDisplay - const date = normalizeDate(value) + const date = normalizeDate(value, this.defaultTimeZone) const resolved = resolveDateFormat( format ?? this.datetimeFormat, "medium", @@ -526,7 +526,7 @@ export class Formatter { /** Returns the value as a UNIX timestamp (seconds since epoch). */ public asTimestamp(value: unknown): string { if (isBlank(value)) return this.nullDisplay - const date = normalizeDate(value) + const date = normalizeDate(value, this.defaultTimeZone) return String(Math.floor(date.getTime() / 1000)) } @@ -537,8 +537,8 @@ export class Formatter { public asRelativeTime(value: unknown, referenceTime?: unknown): string { if (isBlank(value)) return this.nullDisplay - const date = normalizeDate(value) - const ref = referenceTime ? normalizeDate(referenceTime) : new Date() + const date = normalizeDate(value, this.defaultTimeZone) + const ref = referenceTime ? normalizeDate(referenceTime, this.defaultTimeZone) : new Date() const diffMs = date.getTime() - ref.getTime() const diffSec = Math.round(diffMs / 1000) diff --git a/src/global.ts b/src/global.ts index 8991ec8..7772111 100644 --- a/src/global.ts +++ b/src/global.ts @@ -38,7 +38,7 @@ export function configureFormatter(options: FormatterOptions): void { /** * Format a date value safely for display using the global formatter. * Returns `fallback` (default: "\u2014") for null, undefined, empty, or invalid dates. - * Handles timezone-naive ISO strings by treating them as UTC. + * Uses the global formatter's `defaultTimeZone` to reinterpret Z-suffix strings. */ export function formatDate( date: string | Date | null | undefined, @@ -46,7 +46,7 @@ export function formatDate( ): string { if (!date) return fallback try { - const d = parseDate(date) + const d = parseDate(date, instance.defaultTimeZone) if (Number.isNaN(d.getTime())) return fallback return formatter.asDate(d) } catch { diff --git a/src/index.ts b/src/index.ts index c3d8f84..2e68ea4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,4 +30,10 @@ export type { UnitType, UrlOptions, } from "./types.js" -export { escapeHtml, normalizeDate, normalizeNumber, parseDate } from "./utils.js" +export { + escapeHtml, + getIANAOffset, + normalizeDate, + normalizeNumber, + parseDate, +} from "./utils.js" diff --git a/src/utils.ts b/src/utils.ts index f8a984c..03428ac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,11 +13,47 @@ export function escapeHtml(value: string): string { .replace(/'/g, "'") } +/** + * Parse a "GMT+7" / "GMT-5:30" / "GMT" style string from Intl into a + * standard offset like "+07:00" / "-05:30" / "+00:00". + */ +function parseGMTOffset(gmtStr: string): string { + // "GMT" alone means +00:00 + if (gmtStr === "GMT" || gmtStr === "UTC") return "+00:00" + + const match = gmtStr.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/) + if (!match) return "+00:00" + + const sign = match[1] + const hours = (match[2] ?? "0").padStart(2, "0") + const minutes = (match[3] ?? "0").padStart(2, "0") + return `${sign}${hours}:${minutes}` +} + +/** + * Get the UTC offset string (e.g. "+07:00") for an IANA timezone name + * at the given reference date, using only built-in Intl APIs. + */ +export function getIANAOffset(iana: string, refDate: Date): string { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: iana, + timeZoneName: "shortOffset", + }) + const parts = fmt.formatToParts(refDate) + const tzPart = parts.find((p) => p.type === "timeZoneName") + return parseGMTOffset(tzPart?.value ?? "GMT") +} + /** * Normalize an input value into a Date object. * Accepts: Date, number (UNIX seconds or milliseconds), string (ISO 8601). + * + * When `defaultTimeZone` is provided and differs from "UTC", ISO strings + * ending with "Z" are reinterpreted as being in that timezone instead of UTC. + * This handles the common case where backends (Drizzle, Prisma, etc.) append + * "Z" to timestamps that are actually stored in local time. */ -export function normalizeDate(value: unknown): Date { +export function normalizeDate(value: unknown, defaultTimeZone?: string): Date { if (value instanceof Date) return value if (typeof value === "number") { @@ -26,7 +62,19 @@ export function normalizeDate(value: unknown): Date { } if (typeof value === "string") { - const parsed = new Date(value) + let str = value + // Reinterpret Z-suffix strings when defaultTimeZone is not UTC + if ( + defaultTimeZone && + defaultTimeZone !== "UTC" && + str.includes("T") && + str.endsWith("Z") + ) { + const offset = getIANAOffset(defaultTimeZone, new Date(str)) + str = str.slice(0, -1) + offset + } + + const parsed = new Date(str) if (Number.isNaN(parsed.getTime())) { throw new Error(`Cannot parse date value: "${value}"`) } @@ -37,21 +85,37 @@ export function normalizeDate(value: unknown): Date { } /** - * Parse a date string safely, treating timezone-naive ISO strings as UTC. - * Strings like "2024-03-15T14:30:00" (no Z, no +offset) get "Z" appended - * to ensure consistent UTC interpretation across environments. - * Returns an Invalid Date (NaN) for null, undefined, or empty values - * instead of throwing - suitable for display pipelines. + * Parse a date string safely for display, with optional timezone reinterpretation. + * + * - Strings like "2024-03-15T14:30:00" (no Z, no +offset) get "Z" appended + * to ensure consistent UTC interpretation across environments. + * - When `defaultTimeZone` is set and differs from "UTC", Z-suffix strings + * are reinterpreted as being in that timezone (same logic as normalizeDate). + * - Returns an Invalid Date (NaN) for null, undefined, or empty values + * instead of throwing - suitable for display pipelines. */ export function parseDate( date: string | Date | null | undefined, + defaultTimeZone?: string, ): Date { if (!date) return new Date(Number.NaN) if (date instanceof Date) return date let str = date - if (typeof str === "string" && !str.endsWith("Z") && !str.includes("+") && str.includes("T")) { + + // Reinterpret Z-suffix strings when defaultTimeZone is not UTC + if ( + defaultTimeZone && + defaultTimeZone !== "UTC" && + str.includes("T") && + str.endsWith("Z") + ) { + const offset = getIANAOffset(defaultTimeZone, new Date(str)) + str = str.slice(0, -1) + offset + } else if (!str.endsWith("Z") && !str.includes("+") && str.includes("T")) { + // Timezone-naive ISO string: treat as UTC str += "Z" } + return new Date(str) }