Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
138 changes: 137 additions & 1 deletion src/__tests__/formatter-date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})

Expand All @@ -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")
})
})
})
76 changes: 75 additions & 1 deletion src/__tests__/formatter-global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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() ─────────────────────────────────────────────────
Expand Down Expand Up @@ -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() ──────────────────────────────────────────────
Expand Down
12 changes: 6 additions & 6 deletions src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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))
}

Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ 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,
fallback = "\u2014",
): 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 {
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading