diff --git a/.gitignore b/.gitignore index 65e8404..3665038 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +coverage/ *.tgz *.log .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 418bb12..4f091d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `toHijriDate` and all field getters now produce exact round-trips on every host timezone (input Date interpreted by its local calendar day, matching date-fns conventions; previously used raw Date which failed in timezones west of UTC against hijri-core's UTC-day contract). + +### Changed +- `fromHijriDate` and all arithmetic/boundary helpers (`addHijriMonths`, `addHijriYears`, `startOfHijriMonth`, `endOfHijriMonth`) now return **local-midnight** Dates instead of UTC midnight / local noon. Use `getFullYear()`/`getMonth()`/`getDate()` (or date-fns `format()`) on the result — not `toISOString()`. +- Lock-step with unreleased hijri-core `fix/utc-day-boundary` (UTC-day contract). + ## [1.0.2] - 2026-05-30 ### Changed diff --git a/README.md b/README.md index b19d682..a338b6b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,31 @@ Full API reference, guides, and examples: **[Wiki](https://github.com/acamarata/ - [Architecture](https://github.com/acamarata/date-fns-hijri/wiki/Architecture): design decisions and hijri-core integration - [Quick Start](https://github.com/acamarata/date-fns-hijri/wiki/guides/quickstart) +## Day boundaries and time zones + +This package follows date-fns local-time conventions: + +- **Inputs** (`toHijriDate`, `getHijri*`, `formatHijriDate`, arithmetic, comparisons) — the input `Date` is read by its **local calendar day** (using `getFullYear`/`getMonth`/`getDate`). This matches how date-fns' own `format()` and field accessors work. +- **Outputs** (`fromHijriDate` and all arithmetic/boundary functions) — returned `Date` values are **local midnight** of the equivalent Gregorian day. Local field accessors and date-fns' `format()` will render the intended date on every timezone. + +Round-trips are exact on every host timezone: + +```typescript +toHijriDate(fromHijriDate(1446, 9, 1)); // always { hy: 1446, hm: 9, hd: 1 } +``` + +**Pitfall:** `new Date("2025-03-01")` parses as UTC midnight. In timezones west of UTC this resolves to the previous local day (Feb 28), giving an off-by-one result. Use the local-date constructor instead: + +```typescript +// Wrong in timezones west of UTC: +toHijriDate(new Date("2025-03-01")); // may return 29 Shaban in some zones + +// Correct everywhere: +toHijriDate(new Date(2025, 2, 1)); // always 1 Ramadan 1446 +``` + +Religious day-start (sunset boundary) is out of scope — this package only handles civil calendar day alignment. + ## Related - [hijri-core](https://github.com/acamarata/hijri-core): the calendar engine powering this library diff --git a/date-fns-hijri.test.ts b/date-fns-hijri.test.ts new file mode 100644 index 0000000..ce7d6b5 --- /dev/null +++ b/date-fns-hijri.test.ts @@ -0,0 +1,126 @@ +/** + * Purpose: Vitest suite for date-fns-hijri — functional Hijri date utilities. + * Inputs: Pure functions from src/index.ts wrapping hijri-core. No network, no I/O. + * Outputs: Vitest pass/fail assertions. + * Constraints: UAQ range 1318–1500 AH; fromHijriDate throws on invalid input (null path). + * Use local-date constructor new Date(y, m, d) — not string "YYYY-MM-DD" which + * parses as UTC midnight and can be the previous LOCAL day west of UTC. + * Usage: pnpm vitest run + * SOT: packages.md — date-fns-hijri row + */ +import { describe, it, expect } from "vitest"; +import { + toHijriDate, + fromHijriDate, + isValidHijriDate, + getHijriYear, + getHijriMonth, + getHijriDay, + getDaysInHijriMonth, + getHijriMonthName, + getHijriWeekdayName, +} from "./src/index"; + +// Anchor: 1 Ramadan 1446 = 2025-03-01 in the Gregorian calendar. +// Use local-date constructor to avoid the UTC-parsing pitfall with string form. +// At local noon the local calendar day is unambiguous on every timezone. +const RAMADAN_1446_NOON = new Date(2025, 2, 1, 12); // local noon 2025-03-01 + +describe("toHijriDate", () => { + it("converts noon 2025-03-01 UTC to 1 Ramadan 1446", () => { + const result = toHijriDate(RAMADAN_1446_NOON); + expect(result).not.toBeNull(); + expect(result!.hy).toBe(1446); + expect(result!.hm).toBe(9); + expect(result!.hd).toBe(1); + }); + + it("returns null for dates outside UAQ range (2100)", () => { + expect(toHijriDate(new Date("2100-01-01"))).toBeNull(); + }); +}); + +describe("fromHijriDate", () => { + it("converts 1 Ramadan 1446 to local 2025-03-01 (via local accessors)", () => { + const result = fromHijriDate(1446, 9, 1); + // Returns local midnight: local accessors show the intended calendar day + // on every host timezone. Do NOT use toISOString() — it shows UTC which + // will be the previous day in timezones west of UTC. + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(2); // March + expect(result.getDate()).toBe(1); + }); + + it("round-trip: toHijriDate(fromHijriDate(1446, 9, 1)) === {1446, 9, 1}", () => { + const d = fromHijriDate(1446, 9, 1); + const h = toHijriDate(d); + expect(h).not.toBeNull(); + expect(h!.hy).toBe(1446); + expect(h!.hm).toBe(9); + expect(h!.hd).toBe(1); + }); + + it("throws on an out-of-range Hijri year (1501)", () => { + expect(() => fromHijriDate(1501, 1, 1)).toThrow(); + }); +}); + +describe("isValidHijriDate", () => { + it("returns true for 1 Ramadan 1446", () => { + expect(isValidHijriDate(1446, 9, 1)).toBe(true); + }); + + it("returns false for month 13", () => { + expect(isValidHijriDate(1446, 13, 1)).toBe(false); + }); +}); + +describe("field getters", () => { + it("getHijriYear returns 1446 for noon 2025-03-01", () => { + expect(getHijriYear(RAMADAN_1446_NOON)).toBe(1446); + }); + + it("getHijriMonth returns 9 for Ramadan", () => { + expect(getHijriMonth(RAMADAN_1446_NOON)).toBe(9); + }); + + it("getHijriDay returns 1", () => { + expect(getHijriDay(RAMADAN_1446_NOON)).toBe(1); + }); +}); + +describe("getDaysInHijriMonth", () => { + it("returns 29 or 30 for Ramadan 1446", () => { + const days = getDaysInHijriMonth(1446, 9); + expect([29, 30]).toContain(days); + }); +}); + +describe("getHijriMonthName", () => { + it("returns Ramadan for month 9 (long)", () => { + expect(getHijriMonthName(9, "long")).toBe("Ramadan"); + }); + + it("throws RangeError for month 0", () => { + expect(() => getHijriMonthName(0)).toThrow(RangeError); + }); + + it("returns a non-empty medium name for month 1", () => { + const name = getHijriMonthName(1, "medium"); + expect(name.length).toBeGreaterThan(0); + }); +}); + +describe("getHijriWeekdayName", () => { + it("returns a non-empty long weekday name for 2025-03-01 (Saturday)", () => { + const name = getHijriWeekdayName(RAMADAN_1446_NOON, "long"); + expect(typeof name).toBe("string"); + expect(name.length).toBeGreaterThan(0); + }); + + it("short name is no longer than long name for the same date", () => { + const long = getHijriWeekdayName(RAMADAN_1446_NOON, "long"); + const short = getHijriWeekdayName(RAMADAN_1446_NOON, "short"); + expect(short.length).toBeLessThanOrEqual(long.length); + }); +}); diff --git a/package.json b/package.json index eb4b9e5..6e4dc64 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "prepublishOnly": "pnpm run build", "coverage": "c8 --reporter=lcov --reporter=text node test.mjs", "docs": "typedoc --out .github/wiki/api src/index.ts", - "postbuild": "cp dist/index.d.ts dist/index.d.mts" + "postbuild": "cp dist/index.d.ts dist/index.d.mts", + "test:vitest": "vitest run" }, "keywords": [ "date-fns", @@ -74,7 +75,8 @@ "typedoc": "^0.28.19", "typedoc-plugin-markdown": "^4.11.0", "typescript": "^5.5.0", - "typescript-eslint": "^8.56.1" + "typescript-eslint": "^8.56.1", + "vitest": "^2.1.9" }, "publishConfig": { "access": "public", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9c10ed..5e2163a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 3.8.1 tsup: specifier: ^8.0.0 - version: 8.5.1(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) typedoc: specifier: ^0.28.19 version: 0.28.19(typescript@5.9.3) @@ -59,6 +59,9 @@ importers: typescript-eslint: specifier: ^8.56.1 version: 8.56.1(eslint@10.0.3)(typescript@5.9.3) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.3.5) packages: @@ -92,102 +95,204 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -200,6 +305,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -212,6 +323,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -224,24 +341,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -564,6 +705,35 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -599,6 +769,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -637,6 +811,14 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -679,6 +861,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -695,6 +881,14 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -752,10 +946,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -905,6 +1106,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -950,6 +1154,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -984,9 +1193,16 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1019,6 +1235,10 @@ packages: yaml: optional: true + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1066,14 +1286,27 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1110,6 +1343,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -1117,6 +1353,18 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1194,11 +1442,77 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -1249,81 +1563,150 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true @@ -1612,6 +1995,46 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.3.5))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.3.5) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -1639,6 +2062,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -1676,6 +2101,16 @@ snapshots: cac@6.7.14: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1710,6 +2145,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} eastasianwidth@0.2.0: {} @@ -1720,6 +2157,34 @@ snapshots: entities@4.5.0: {} + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -1819,8 +2284,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1948,6 +2419,8 @@ snapshots: dependencies: p-locate: 5.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lunr@2.3.9: {} @@ -2000,6 +2473,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.12: {} + natural-compare@1.4.0: {} object-assign@4.1.1: {} @@ -2032,8 +2507,12 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -2046,12 +2525,19 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1(yaml@2.9.0): + postcss-load-config@6.0.1(postcss@8.5.15)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: + postcss: 8.5.15 yaml: 2.9.0 + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -2105,10 +2591,18 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} + source-map@0.7.6: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2157,6 +2651,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} tinyglobby@0.2.15: @@ -2164,6 +2660,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + tree-kill@1.2.2: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -2172,7 +2674,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -2183,7 +2685,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(yaml@2.9.0) + postcss-load-config: 6.0.1(postcss@8.5.15)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -2192,6 +2694,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + postcss: 8.5.15 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -2245,10 +2748,77 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + vite-node@2.1.9(@types/node@25.3.5): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.3.5) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@25.3.5): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 25.3.5 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@25.3.5): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.3.5)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.3.5) + vite-node: 2.1.9(@types/node@25.3.5) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.3.5 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/src/index.ts b/src/index.ts index 37cf7a9..1bfc466 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,27 @@ export type { HijriDate, CalendarEngine, ConversionOptions } from "./types"; import type { HijriDate, ConversionOptions } from "./types"; +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Purpose: Lift a Date's LOCAL calendar components (year, month, day) into the + * UTC slot so that hijri-core's UTC-day contract reads the caller's + * intended calendar day regardless of host timezone. + * Inputs: Any Gregorian Date. + * Outputs: A new Date whose UTC year/month/date equal the input's LOCAL year/month/date. + * Constraints: Used only as input to coreToHijri; the returned value is an ephemeral + * intermediate — never hand it to Date#getFullYear or date-fns functions. + * WHY: date-fns is a LOCAL-time library: its functions read local components. + * hijri-core (after fix/utc-day-boundary) reads the UTC calendar day. + * Without this shim, hosts west of UTC see the previous UTC day for + * a local-midnight Date, causing off-by-one conversions. + */ +function localDayToUtcSlot(date: Date): Date { + return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); +} + // --------------------------------------------------------------------------- // Conversion // --------------------------------------------------------------------------- @@ -22,19 +43,39 @@ import type { HijriDate, ConversionOptions } from "./types"; /** * Convert a Gregorian `Date` to a Hijri date object. * + * Follows date-fns conventions: the input `Date` is interpreted by its + * **local calendar day** (year/month/date in the host timezone). This matches + * how date-fns' own `format()` and field accessors work, so there are no + * timezone surprises when chaining with other date-fns functions. + * * Returns `null` when the date falls outside the calendar's supported range * (UAQ: 1318–1500 AH / 1900–2076 CE; FCNA extends slightly further). + * + * @example + * // Use local-date constructor, not the string form "2025-03-01" (parses as UTC) + * toHijriDate(new Date(2025, 2, 1)); // { hy: 1446, hm: 9, hd: 1 } */ export function toHijriDate(date: Date, options?: ConversionOptions): HijriDate | null { - return coreToHijri(date, options); + return coreToHijri(localDayToUtcSlot(date), options); } /** * Convert a Hijri date to a Gregorian `Date`. * - * The returned `Date` is set to midnight UTC of the equivalent Gregorian day. + * Returns a **local-midnight** Date so that local field accessors + * (`getFullYear`, `getMonth`, `getDate`) and date-fns' `format()` render the + * intended calendar day on every host timezone. + * + * Round-trips exactly: `toHijriDate(fromHijriDate(y, m, d))` returns + * `{ hy: y, hm: m, hd: d }` on every timezone. * * @throws {Error} If the Hijri date is invalid or outside the calendar's range. + * + * @example + * const d = fromHijriDate(1446, 9, 1); + * d.getFullYear(); // 2025 + * d.getMonth(); // 2 (March) + * d.getDate(); // 1 */ export function fromHijriDate( hy: number, @@ -42,11 +83,13 @@ export function fromHijriDate( hd: number, options?: ConversionOptions, ): Date { - const result = coreToGregorian(hy, hm, hd, options); - if (result === null) { + const greg = coreToGregorian(hy, hm, hd, options); + if (greg === null) { throw new Error(`Hijri date ${hy}/${hm}/${hd} is invalid or outside the supported range.`); } - return result; + // coreToGregorian returns UTC midnight; lift to local midnight so that + // local field accessors and date-fns format() show the right calendar day. + return new Date(greg.getUTCFullYear(), greg.getUTCMonth(), greg.getUTCDate()); } // --------------------------------------------------------------------------- @@ -75,28 +118,34 @@ export function isValidHijriDate( /** * Get the Hijri year for a Gregorian date. * + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * * Returns `null` when the date is outside the supported range. */ export function getHijriYear(date: Date, options?: ConversionOptions): number | null { - return coreToHijri(date, options)?.hy ?? null; + return coreToHijri(localDayToUtcSlot(date), options)?.hy ?? null; } /** * Get the Hijri month (1–12) for a Gregorian date. * + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * * Returns `null` when the date is outside the supported range. */ export function getHijriMonth(date: Date, options?: ConversionOptions): number | null { - return coreToHijri(date, options)?.hm ?? null; + return coreToHijri(localDayToUtcSlot(date), options)?.hm ?? null; } /** * Get the Hijri day of month (1–30) for a Gregorian date. * + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * * Returns `null` when the date is outside the supported range. */ export function getHijriDay(date: Date, options?: ConversionOptions): number | null { - return coreToHijri(date, options)?.hd ?? null; + return coreToHijri(localDayToUtcSlot(date), options)?.hd ?? null; } /** @@ -141,6 +190,8 @@ export function getHijriMonthName( * Get the Arabic weekday name for a Gregorian date. * * Uses `Date.getDay()` (0 = Sunday, 6 = Saturday) as the index. + * `getDay()` reads the local weekday, which is correct — weekday display + * follows the host's local calendar day just like date-fns. * * @param date - Any Gregorian `Date`. * @param length - `'long'` (default) or `'short'`. @@ -162,6 +213,9 @@ const TOKEN_RE = /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo/g; /** * Format a Gregorian date using Hijri calendar tokens. * + * The input Date is interpreted by its **local calendar day** (date-fns convention), + * matching the behavior of date-fns' own `format()`. + * * Supported tokens: * * | Token | Output | Example | @@ -187,10 +241,10 @@ export function formatHijriDate( formatStr: string, options?: ConversionOptions, ): string { - const h = coreToHijri(date, options); + const h = coreToHijri(localDayToUtcSlot(date), options); if (!h) return ""; - const day = date.getDay(); // 0–6 + const day = date.getDay(); // 0–6 local weekday — correct for display return formatStr.replace(TOKEN_RE, (token): string => { switch (token) { @@ -233,24 +287,6 @@ export function formatHijriDate( }); } -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** - * `coreToGregorian` returns a UTC-midnight Date. When `coreToHijri` is then - * called on that Date, it normalises using local year/month/day components - * (`getFullYear`, `getMonth`, `getDate`). In timezones west of UTC the local - * date of a UTC-midnight instant is the *previous* calendar day, which causes - * the round-trip to drift by one day. - * - * This helper converts a UTC-midnight Date to a local-noon Date so that local - * calendar components always match the intended Gregorian date. - */ -function utcMidnightToLocalNoon(d: Date): Date { - return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12); -} - // --------------------------------------------------------------------------- // Arithmetic // --------------------------------------------------------------------------- @@ -258,6 +294,9 @@ function utcMidnightToLocalNoon(d: Date): Date { /** * Add a number of Hijri months to a Gregorian date. * + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * Returns a **local-midnight** Date. + * * Handles year rollover automatically. Month addition wraps at month 12 and * increments the year. If the result's month has fewer days than the original * day, the day is clamped to the last day of the new month. @@ -265,7 +304,7 @@ function utcMidnightToLocalNoon(d: Date): Date { * @throws {Error} If the resulting Hijri date is outside the supported range. */ export function addHijriMonths(date: Date, months: number, options?: ConversionOptions): Date { - const h = coreToHijri(date, options); + const h = coreToHijri(localDayToUtcSlot(date), options); if (!h) { throw new Error("Date is outside the supported Hijri calendar range."); } @@ -279,19 +318,22 @@ export function addHijriMonths(date: Date, months: number, options?: ConversionO const maxDay = coreDaysInHijriMonth(newYear, newMonth, options); const newDay = Math.min(h.hd, maxDay); - return utcMidnightToLocalNoon(fromHijriDate(newYear, newMonth, newDay, options)); + return fromHijriDate(newYear, newMonth, newDay, options); } /** * Add a number of Hijri years to a Gregorian date. * - * If the resulting year has a shorter Ramadan (or any month) than the original - * day, the day is clamped to the last day of that month. + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * Returns a **local-midnight** Date. + * + * If the resulting year has a shorter month than the original day, the day is + * clamped to the last day of that month. * * @throws {Error} If the resulting Hijri date is outside the supported range. */ export function addHijriYears(date: Date, years: number, options?: ConversionOptions): Date { - const h = coreToHijri(date, options); + const h = coreToHijri(localDayToUtcSlot(date), options); if (!h) { throw new Error("Date is outside the supported Hijri calendar range."); } @@ -300,7 +342,7 @@ export function addHijriYears(date: Date, years: number, options?: ConversionOpt const maxDay = coreDaysInHijriMonth(newYear, h.hm, options); const newDay = Math.min(h.hd, maxDay); - return utcMidnightToLocalNoon(fromHijriDate(newYear, h.hm, newDay, options)); + return fromHijriDate(newYear, h.hm, newDay, options); } // --------------------------------------------------------------------------- @@ -310,28 +352,34 @@ export function addHijriYears(date: Date, years: number, options?: ConversionOpt /** * Get the first day of the Hijri month that contains the given date. * + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * Returns a **local-midnight** Date. + * * @throws {Error} If the date is outside the supported range. */ export function startOfHijriMonth(date: Date, options?: ConversionOptions): Date { - const h = coreToHijri(date, options); + const h = coreToHijri(localDayToUtcSlot(date), options); if (!h) { throw new Error("Date is outside the supported Hijri calendar range."); } - return utcMidnightToLocalNoon(fromHijriDate(h.hy, h.hm, 1, options)); + return fromHijriDate(h.hy, h.hm, 1, options); } /** * Get the last day of the Hijri month that contains the given date. * + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * Returns a **local-midnight** Date. + * * @throws {Error} If the date is outside the supported range. */ export function endOfHijriMonth(date: Date, options?: ConversionOptions): Date { - const h = coreToHijri(date, options); + const h = coreToHijri(localDayToUtcSlot(date), options); if (!h) { throw new Error("Date is outside the supported Hijri calendar range."); } const lastDay = coreDaysInHijriMonth(h.hy, h.hm, options); - return utcMidnightToLocalNoon(fromHijriDate(h.hy, h.hm, lastDay, options)); + return fromHijriDate(h.hy, h.hm, lastDay, options); } // --------------------------------------------------------------------------- @@ -341,11 +389,13 @@ export function endOfHijriMonth(date: Date, options?: ConversionOptions): Date { /** * Check whether two Gregorian dates fall in the same Hijri month. * + * Both input Dates are interpreted by their **local calendar days** (date-fns convention). + * * Returns `false` if either date is outside the supported range. */ export function isSameHijriMonth(dateA: Date, dateB: Date, options?: ConversionOptions): boolean { - const a = coreToHijri(dateA, options); - const b = coreToHijri(dateB, options); + const a = coreToHijri(localDayToUtcSlot(dateA), options); + const b = coreToHijri(localDayToUtcSlot(dateB), options); if (!a || !b) return false; return a.hy === b.hy && a.hm === b.hm; } @@ -353,11 +403,13 @@ export function isSameHijriMonth(dateA: Date, dateB: Date, options?: ConversionO /** * Check whether two Gregorian dates fall in the same Hijri year. * + * Both input Dates are interpreted by their **local calendar days** (date-fns convention). + * * Returns `false` if either date is outside the supported range. */ export function isSameHijriYear(dateA: Date, dateB: Date, options?: ConversionOptions): boolean { - const a = coreToHijri(dateA, options); - const b = coreToHijri(dateB, options); + const a = coreToHijri(localDayToUtcSlot(dateA), options); + const b = coreToHijri(localDayToUtcSlot(dateB), options); if (!a || !b) return false; return a.hy === b.hy; } @@ -369,12 +421,14 @@ export function isSameHijriYear(dateA: Date, dateB: Date, options?: ConversionOp /** * Get the Hijri quarter (1–4) for a Gregorian date. * + * The input Date is interpreted by its **local calendar day** (date-fns convention). + * * Months 1–3 = Q1, 4–6 = Q2, 7–9 = Q3, 10–12 = Q4. * * Returns `null` when the date is outside the supported range. */ export function getHijriQuarter(date: Date, options?: ConversionOptions): number | null { - const h = coreToHijri(date, options); + const h = coreToHijri(localDayToUtcSlot(date), options); if (!h) return null; return Math.ceil(h.hm / 3); } diff --git a/test-cjs.cjs b/test-cjs.cjs index 190f991..faa36b9 100644 --- a/test-cjs.cjs +++ b/test-cjs.cjs @@ -26,11 +26,12 @@ describe('CJS: toHijriDate', () => { }); describe('CJS: fromHijriDate', () => { - it('converts to correct Gregorian date', () => { + it('converts to correct Gregorian date (local midnight)', () => { const d = fromHijriDate(1444, 9, 1); - assert.equal(d.getUTCFullYear(), 2023); - assert.equal(d.getUTCMonth(), 2); - assert.equal(d.getUTCDate(), 23); + // Returns local midnight — use local accessors, not UTC + assert.equal(d.getFullYear(), 2023); + assert.equal(d.getMonth(), 2); + assert.equal(d.getDate(), 23); }); }); diff --git a/test.mjs b/test.mjs index c7b24a0..2894815 100644 --- a/test.mjs +++ b/test.mjs @@ -43,21 +43,48 @@ describe('toHijriDate', () => { const h = toHijriDate(new Date(1800, 0, 1)); assert.equal(h, null); }); + + it('toHijriDate(new Date(2025, 2, 1, 12)) -> {1446, 9, 1}', () => { + // Local-noon: verifies local-day interpretation ignores the time component + const h = toHijriDate(new Date(2025, 2, 1, 12)); + assert.ok(h !== null, 'expected non-null'); + assert.equal(h.hy, 1446); + assert.equal(h.hm, 9); + assert.equal(h.hd, 1); + }); }); describe('fromHijriDate', () => { - it('1 Ramadan 1444 -> 2023-03-23', () => { + it('1 Ramadan 1444 -> local 2023-03-23', () => { const d = fromHijriDate(1444, 9, 1); - assert.equal(d.getUTCFullYear(), 2023); - assert.equal(d.getUTCMonth(), 2); - assert.equal(d.getUTCDate(), 23); + // Returns local midnight: local accessors show the intended calendar day + assert.equal(d.getFullYear(), 2023); + assert.equal(d.getMonth(), 2); + assert.equal(d.getDate(), 23); }); - it('1 Muharram 1446 -> 2024-07-07', () => { + it('1 Muharram 1446 -> local 2024-07-07', () => { const d = fromHijriDate(1446, 1, 1); - assert.equal(d.getUTCFullYear(), 2024); - assert.equal(d.getUTCMonth(), 6); - assert.equal(d.getUTCDate(), 7); + assert.equal(d.getFullYear(), 2024); + assert.equal(d.getMonth(), 6); + assert.equal(d.getDate(), 7); + }); + + it('round-trip: toHijriDate(fromHijriDate(1446, 9, 1)) === {1446, 9, 1}', () => { + const d = fromHijriDate(1446, 9, 1); + const h = toHijriDate(d); + assert.ok(h !== null, 'expected non-null round-trip result'); + assert.equal(h.hy, 1446); + assert.equal(h.hm, 9); + assert.equal(h.hd, 1); + }); + + it('fromHijriDate(1446,9,1) local accessors show 2025-03-01', () => { + const d = fromHijriDate(1446, 9, 1); + // Local accessors — not toISOString() — are the correct API for this adapter + assert.equal(d.getFullYear(), 2025); + assert.equal(d.getMonth(), 2); // March + assert.equal(d.getDate(), 1); }); it('throws on invalid month', () => { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..225657d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["date-fns-hijri.test.ts"], + }, +});