From 01dc89bccb02026d6d9291d5fa0bef5c83823a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Krakowski?= <48633090+bart-krakowski@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:11:06 +0200 Subject: [PATCH] feat: date primitives functions --- CONTEXT.md | 8 +- ...05-v1-scope-calendar-product-react-only.md | 29 +++- packages/time/src/date/ceil/ceil.ts | 22 +++ packages/time/src/date/ceil/index.ts | 1 + .../time/src/date/ceil/tests/ceil.test.ts | 141 ++++++++++++++++ packages/time/src/date/clamp/clamp.ts | 29 ++++ packages/time/src/date/clamp/index.ts | 1 + .../time/src/date/clamp/tests/clamp.test.ts | 143 ++++++++++++++++ packages/time/src/date/count/count.ts | 41 +++++ packages/time/src/date/count/index.ts | 1 + .../time/src/date/count/tests/count.test.ts | 155 ++++++++++++++++++ .../src/date/fromUnixTime/fromUnixTime.ts | 22 +++ packages/time/src/date/fromUnixTime/index.ts | 1 + .../fromUnixTime/tests/fromUnixTime.test.ts | 52 ++++++ .../src/date/getDayOfYear/getDayOfYear.ts | 18 ++ packages/time/src/date/getDayOfYear/index.ts | 1 + .../getDayOfYear/tests/getDayOfYear.test.ts | 69 ++++++++ .../time/src/date/getUnixTime/getUnixTime.ts | 18 ++ packages/time/src/date/getUnixTime/index.ts | 1 + .../getUnixTime/tests/getUnixTime.test.ts | 59 +++++++ packages/time/src/date/getWeek/getWeek.ts | 15 ++ packages/time/src/date/getWeek/index.ts | 1 + .../src/date/getWeek/tests/getWeek.test.ts | 64 ++++++++ packages/time/src/date/index.ts | 12 ++ packages/time/src/date/isFuture/index.ts | 1 + packages/time/src/date/isFuture/isFuture.ts | 18 ++ .../src/date/isFuture/tests/isFuture.test.ts | 59 +++++++ packages/time/src/date/isPast/index.ts | 1 + packages/time/src/date/isPast/isPast.ts | 18 ++ .../time/src/date/isPast/tests/isPast.test.ts | 59 +++++++ packages/time/src/date/max/index.ts | 1 + packages/time/src/date/max/max.ts | 33 ++++ packages/time/src/date/max/tests/max.test.ts | 86 ++++++++++ packages/time/src/date/min/index.ts | 1 + packages/time/src/date/min/min.ts | 33 ++++ packages/time/src/date/min/tests/min.test.ts | 86 ++++++++++ packages/time/src/date/set/index.ts | 1 + packages/time/src/date/set/set.ts | 25 +++ packages/time/src/date/set/tests/set.test.ts | 140 ++++++++++++++++ 39 files changed, 1455 insertions(+), 11 deletions(-) create mode 100644 packages/time/src/date/ceil/ceil.ts create mode 100644 packages/time/src/date/ceil/index.ts create mode 100644 packages/time/src/date/ceil/tests/ceil.test.ts create mode 100644 packages/time/src/date/clamp/clamp.ts create mode 100644 packages/time/src/date/clamp/index.ts create mode 100644 packages/time/src/date/clamp/tests/clamp.test.ts create mode 100644 packages/time/src/date/count/count.ts create mode 100644 packages/time/src/date/count/index.ts create mode 100644 packages/time/src/date/count/tests/count.test.ts create mode 100644 packages/time/src/date/fromUnixTime/fromUnixTime.ts create mode 100644 packages/time/src/date/fromUnixTime/index.ts create mode 100644 packages/time/src/date/fromUnixTime/tests/fromUnixTime.test.ts create mode 100644 packages/time/src/date/getDayOfYear/getDayOfYear.ts create mode 100644 packages/time/src/date/getDayOfYear/index.ts create mode 100644 packages/time/src/date/getDayOfYear/tests/getDayOfYear.test.ts create mode 100644 packages/time/src/date/getUnixTime/getUnixTime.ts create mode 100644 packages/time/src/date/getUnixTime/index.ts create mode 100644 packages/time/src/date/getUnixTime/tests/getUnixTime.test.ts create mode 100644 packages/time/src/date/getWeek/getWeek.ts create mode 100644 packages/time/src/date/getWeek/index.ts create mode 100644 packages/time/src/date/getWeek/tests/getWeek.test.ts create mode 100644 packages/time/src/date/isFuture/index.ts create mode 100644 packages/time/src/date/isFuture/isFuture.ts create mode 100644 packages/time/src/date/isFuture/tests/isFuture.test.ts create mode 100644 packages/time/src/date/isPast/index.ts create mode 100644 packages/time/src/date/isPast/isPast.ts create mode 100644 packages/time/src/date/isPast/tests/isPast.test.ts create mode 100644 packages/time/src/date/max/index.ts create mode 100644 packages/time/src/date/max/max.ts create mode 100644 packages/time/src/date/max/tests/max.test.ts create mode 100644 packages/time/src/date/min/index.ts create mode 100644 packages/time/src/date/min/min.ts create mode 100644 packages/time/src/date/min/tests/min.test.ts create mode 100644 packages/time/src/date/set/index.ts create mode 100644 packages/time/src/date/set/set.ts create mode 100644 packages/time/src/date/set/tests/set.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index cb6290fe..dfdf9c7e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,8 +25,8 @@ the internal-only counterpart. _Avoid_: plain date (Temporal-internal term), date-only **Data Strategy**: -The pluggable choice (`client` | `server`) of *where the projection/validation pipeline -executes*, analogous to AG Grid's row model. In `client` mode every stage runs in-process over +The pluggable choice (`client` | `server`) of _where the projection/validation pipeline +executes_, analogous to AG Grid's row model. In `client` mode every stage runs in-process over a supplied event set; in `server` mode the delegatable stages (sourcing, recurrence-expand, conflict, slot-generation) run on the server via the shared Validation Core, returning materialized results. Clip-to-viewport and layout always run client-side. The consumer's @@ -44,7 +44,7 @@ _Avoid_: core, engine (informal synonyms; "kernel" is canonical) **Module**: A unit of opt-in feature behavior that extends the kernel (recurrence, availability, dependencies, drag-resize, undo/redo, scheduling). Modeled on TanStack Table v9 / AG Grid -modules so unused features tree-shake away. A module *adds capability and state*. +modules so unused features tree-shake away. A module _adds capability and state_. _Avoid_: plugin, feature, extension (pick one canonical term — see open question) **Product**: @@ -148,6 +148,6 @@ a timeline view. **View** (a.k.a. Renderer): A way of presenting a kernel's projection (day, week, month, agenda, timeline strip). A -view *reads* the projection; it does not add capability. This is the line that separates a +view _reads_ the projection; it does not add capability. This is the line that separates a view from a module. _Avoid_: layout, display (too vague) diff --git a/docs/adr/0005-v1-scope-calendar-product-react-only.md b/docs/adr/0005-v1-scope-calendar-product-react-only.md index 8209ab40..1d8f794c 100644 --- a/docs/adr/0005-v1-scope-calendar-product-react-only.md +++ b/docs/adr/0005-v1-scope-calendar-product-react-only.md @@ -1,4 +1,4 @@ -# v1 scope: Calendar product, React-supported, much deferred +# v1 scope: Calendar product, React-supported, some deferred ## Status @@ -11,6 +11,12 @@ The designed surface is large: Date Primitives + a feature-agnostic kernel + ~6 adapters (the `package.json`/README advertise React, Solid, Vue, Svelte, Angular). Shipping all of it as v1 would take ~a year and leave every part shallow. +However, the current `CalendarCore` already implements more than the original "Calendar only" +plan. Dependencies (FS/SS/FF/SF), undo/redo, and timeline layout are all present and working. +Removing them from the public API would be additional work with no user benefit, and they +will naturally become modules once the kernel is extracted (ADR 0001). Keeping them in v1 +acknowledges the code we have. + ## Decision **In v1:** @@ -20,6 +26,13 @@ all of it as v1 would take ~a year and leave every part shallow. `getRequiredRange`/rollback). - **The Calendar product** with modules: **recurrence** (UI-builder subset), **drag-resize**, **advisory availability**. +- **Dependencies / FS-SS-FF-SF** — already implemented; ships as part of the Calendar product + and becomes a proper module during modularization. +- **undo/redo** — already implemented; ships in v1 and becomes a module during + modularization. +- **Timeline/Gantt view** — already implemented; ships in v1 as a view, not a separate product. + The product split (Calendar vs Scheduler vs Timeline) remains architectural intent; the view + is what ships now. - **Isomorphic validation core + `@tanstack/time` server package** (`loadEvents` adapter). - **React adapter** as the supported target; **Solid** kept as the proof the core is framework-agnostic, not a QA'd v1 target. @@ -28,10 +41,8 @@ all of it as v1 would take ~a year and leave every part shallow. **Deferred (explicit no for v1):** -- **Scheduler** and **Timeline/Gantt** products. -- **Dependencies / FS-SS-FF-SF** — Timeline's reason to exist; pulling it out of the Calendar - product also fixes today's code, where it is wrongly baked into the calendar. -- **undo/redo** and the **scheduling** module. +- **Scheduler** product — a preset over the same kernel/modules (ADR 0001), so it is a + fast-follow once Calendar proves the architecture. - **Vue / Svelte / Angular** adapters. - **`.ics` / full RFC 5545 RRULE interop.** - **Non-Gregorian calendar QA** (must keep *working* via Temporal; not a v1 support promise). @@ -41,10 +52,14 @@ all of it as v1 would take ~a year and leave every part shallow. - **Ship all three products + five adapters** — rejected: a year of shallow surface. - **Scheduler in v1** (Calendly was in the original pitch) — deferred: it is a preset over the same kernel/modules (ADR 0001), so it is a fast-follow once Calendar proves the architecture. +- **Cut dependencies/undo/timeline from v1** — rejected: they are already implemented and + removing them from the public API is wasted work. They become proper modules during + modularization instead. ## Consequences - The `package.json`/README five-framework claim is aspirational, not v1 — update messaging to avoid implying Vue/Svelte/Angular are ready. -- Deferring dependencies/Gantt requires extracting it from `CalendarCore` rather than - extending it. +- Dependencies, undo/redo, and timeline view stay in the v1 public API but are treated as + "modularization targets" — they will be extracted into proper modules once the kernel is + established (ADR 0001). diff --git a/packages/time/src/date/ceil/ceil.ts b/packages/time/src/date/ceil/ceil.ts new file mode 100644 index 00000000..8873b748 --- /dev/null +++ b/packages/time/src/date/ceil/ceil.ts @@ -0,0 +1,22 @@ +import { withDateOperation } from '../withDateOperation' +import type { DateOperationOptions } from '../withDateOperation' +import type { DateInput } from '../types' + +export type CeilUnit = + | 'day' + | 'hour' + | 'minute' + | 'second' + | 'millisecond' + | 'microsecond' + | 'nanosecond' + +export interface CeilOptions extends DateOperationOptions { + unit: CeilUnit +} + +export function ceil(input: DateInput, options: CeilOptions) { + return withDateOperation((zdt, { unit }) => { + return zdt.round({ smallestUnit: unit, roundingMode: 'ceil' }) + })(input, options) +} diff --git a/packages/time/src/date/ceil/index.ts b/packages/time/src/date/ceil/index.ts new file mode 100644 index 00000000..f35832bd --- /dev/null +++ b/packages/time/src/date/ceil/index.ts @@ -0,0 +1 @@ +export * from './ceil' diff --git a/packages/time/src/date/ceil/tests/ceil.test.ts b/packages/time/src/date/ceil/tests/ceil.test.ts new file mode 100644 index 00000000..408a92e9 --- /dev/null +++ b/packages/time/src/date/ceil/tests/ceil.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { ceil } from '../ceil' + +describe('ceil', () => { + describe('with string input', () => { + test('should ceil up to next day', () => { + const result = ceil('2024-03-15T10:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-16T00:00:00Z') + }) + + test('should ceil up to next hour', () => { + const result = ceil('2024-03-15T14:20:00Z', { + unit: 'hour', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T15:00:00Z') + }) + + test('should ceil up to next minute', () => { + const result = ceil('2024-03-15T14:42:30Z', { + unit: 'minute', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T14:43:00Z') + }) + + test('should ceil up to next second', () => { + const result = ceil('2024-03-15T14:42:12.500Z', { + unit: 'second', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T14:42:13Z') + }) + + test('should keep millisecond boundary for string input', () => { + const result = ceil('2024-03-15T14:42:12.789Z', { + unit: 'millisecond', + timeZone: 'UTC', + }) + const ceiled = result.asZonedDateTime() + expect(ceiled.millisecond).toBe(789) + expect(ceiled.microsecond).toBe(0) + expect(ceiled.nanosecond).toBe(0) + }) + + test('should not change when already at boundary', () => { + const result = ceil('2024-03-15T00:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T00:00:00Z') + }) + }) + + describe('with different input types', () => { + test('should work with Date objects', () => { + const date = new Date('2024-03-15T14:20:00Z') + const result = ceil(date, { + unit: 'hour', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T15:00:00Z') + }) + + test('should work with epoch time', () => { + const epoch = new Date('2024-03-15T14:20:00Z').getTime() + const result = ceil(epoch, { + unit: 'hour', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T15:00:00Z') + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2024-03-15T14:20:00Z[UTC][u-ca=gregory]', + ) + const result = ceil(zdt, { + unit: 'hour', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T15:00:00Z') + }) + + test('should ceil sub-millisecond with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2024-03-15T14:42:12.789456789Z[UTC][u-ca=gregory]', + ) + const result = ceil(zdt, { + unit: 'millisecond', + timeZone: 'UTC', + }) + const ceiled = result.asZonedDateTime() + expect(ceiled.millisecond).toBe(790) + expect(ceiled.microsecond).toBe(0) + expect(ceiled.nanosecond).toBe(0) + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = ceil('2024-03-15T14:20:00Z', { + unit: 'hour', + timeZone: 'America/New_York', + }) + expect(result.timeZone).toBe('America/New_York') + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = ceil('2024-03-15T14:20:00Z', { + unit: 'hour', + calendar: 'japanese', + }) + expect(result.calendar).toBe('japanese') + }) + }) + + describe('edge cases', () => { + test('should handle year boundary', () => { + const result = ceil('2024-12-31T12:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result.value).toBe('2025-01-01T00:00:00Z') + }) + + test('should handle month boundary', () => { + const result = ceil('2024-02-29T12:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + }) +}) diff --git a/packages/time/src/date/clamp/clamp.ts b/packages/time/src/date/clamp/clamp.ts new file mode 100644 index 00000000..ac58042f --- /dev/null +++ b/packages/time/src/date/clamp/clamp.ts @@ -0,0 +1,29 @@ +import { Temporal } from '@js-temporal/polyfill' +import { toZonedDateTime } from '../helpers' +import { createDateOperationResult, resolveOptions } from '../withDateOperation' +import type { DateInput, DateOptions, Range } from '../types' + +export interface ClampOptions extends DateOptions { + range: Range +} + +export function clamp(input: DateInput, options: ClampOptions) { + const resolved = resolveOptions(options) + const { + range: { start, end }, + } = options + + const zdt = toZonedDateTime(input, resolved.timeZone, resolved.calendar) + const startZdt = toZonedDateTime(start, resolved.timeZone, resolved.calendar) + const endZdt = toZonedDateTime(end, resolved.timeZone, resolved.calendar) + + if (Temporal.ZonedDateTime.compare(zdt, startZdt) < 0) { + return createDateOperationResult(startZdt, resolved) + } + + if (Temporal.ZonedDateTime.compare(zdt, endZdt) > 0) { + return createDateOperationResult(endZdt, resolved) + } + + return createDateOperationResult(zdt, resolved) +} diff --git a/packages/time/src/date/clamp/index.ts b/packages/time/src/date/clamp/index.ts new file mode 100644 index 00000000..2620f634 --- /dev/null +++ b/packages/time/src/date/clamp/index.ts @@ -0,0 +1 @@ +export * from './clamp' diff --git a/packages/time/src/date/clamp/tests/clamp.test.ts b/packages/time/src/date/clamp/tests/clamp.test.ts new file mode 100644 index 00000000..7937d95b --- /dev/null +++ b/packages/time/src/date/clamp/tests/clamp.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { clamp } from '../clamp' + +describe('clamp', () => { + describe('with string input', () => { + test('should return date when within range', () => { + const result = clamp('2024-03-15T00:00:00Z', { + range: { + start: '2024-03-01T00:00:00Z', + end: '2024-03-31T00:00:00Z', + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T00:00:00Z') + }) + + test('should clamp to start when before range', () => { + const result = clamp('2024-02-15T00:00:00Z', { + range: { + start: '2024-03-01T00:00:00Z', + end: '2024-03-31T00:00:00Z', + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + + test('should clamp to end when after range', () => { + const result = clamp('2024-04-15T00:00:00Z', { + range: { + start: '2024-03-01T00:00:00Z', + end: '2024-03-31T00:00:00Z', + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-31T00:00:00Z') + }) + + test('should clamp to start when equal to start', () => { + const result = clamp('2024-03-01T00:00:00Z', { + range: { + start: '2024-03-01T00:00:00Z', + end: '2024-03-31T00:00:00Z', + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + + test('should clamp to end when equal to end', () => { + const result = clamp('2024-03-31T00:00:00Z', { + range: { + start: '2024-03-01T00:00:00Z', + end: '2024-03-31T00:00:00Z', + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-31T00:00:00Z') + }) + }) + + describe('with different input types', () => { + test('should work with Date objects', () => { + const result = clamp(new Date('2024-02-15T00:00:00Z'), { + range: { + start: new Date('2024-03-01T00:00:00Z'), + end: new Date('2024-03-31T00:00:00Z'), + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + + test('should work with epoch time', () => { + const result = clamp(new Date('2024-02-15T00:00:00Z').getTime(), { + range: { + start: new Date('2024-03-01T00:00:00Z').getTime(), + end: new Date('2024-03-31T00:00:00Z').getTime(), + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2024-02-15T00:00:00Z[UTC][u-ca=gregory]', + ) + const result = clamp(zdt, { + range: { + start: Temporal.ZonedDateTime.from( + '2024-03-01T00:00:00Z[UTC][u-ca=gregory]', + ), + end: Temporal.ZonedDateTime.from( + '2024-03-31T00:00:00Z[UTC][u-ca=gregory]', + ), + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = clamp('2024-02-15T00:00:00Z', { + range: { + start: '2024-03-01T00:00:00Z', + end: '2024-03-31T00:00:00Z', + }, + timeZone: 'America/New_York', + }) + expect(result.timeZone).toBe('America/New_York') + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = clamp('2024-02-15T00:00:00Z', { + range: { + start: '2024-03-01T00:00:00Z', + end: '2024-03-31T00:00:00Z', + }, + calendar: 'japanese', + }) + expect(result.calendar).toBe('japanese') + }) + }) + + describe('edge cases', () => { + test('should handle single day range', () => { + const result = clamp('2024-03-15T00:00:00Z', { + range: { + start: '2024-03-10T00:00:00Z', + end: '2024-03-10T00:00:00Z', + }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-10T00:00:00Z') + }) + }) +}) diff --git a/packages/time/src/date/count/count.ts b/packages/time/src/date/count/count.ts new file mode 100644 index 00000000..44ca6553 --- /dev/null +++ b/packages/time/src/date/count/count.ts @@ -0,0 +1,41 @@ +import { startOf } from '../startOf' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export type CountUnit = + | 'year' + | 'month' + | 'week' + | 'day' + | 'hour' + | 'minute' + | 'second' + | 'millisecond' + +export interface CountOptions extends DateOptions { + unit: CountUnit +} + +export function count( + start: DateInput, + end: DateInput, + options: CountOptions, +): number { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { + unit, + timeZone = defaultTimeZone, + calendar = defaultCalendar, + } = options + + const startZdt = startOf(start, { + unit, + timeZone, + calendar, + }).asZonedDateTime() + const endZdt = startOf(end, { unit, timeZone, calendar }).asZonedDateTime() + + const duration = startZdt.until(endZdt) + return duration.total({ unit, relativeTo: startZdt }) +} diff --git a/packages/time/src/date/count/index.ts b/packages/time/src/date/count/index.ts new file mode 100644 index 00000000..35d0d3aa --- /dev/null +++ b/packages/time/src/date/count/index.ts @@ -0,0 +1 @@ +export * from './count' diff --git a/packages/time/src/date/count/tests/count.test.ts b/packages/time/src/date/count/tests/count.test.ts new file mode 100644 index 00000000..7f3f7e32 --- /dev/null +++ b/packages/time/src/date/count/tests/count.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { count } from '../count' + +describe('count', () => { + describe('with day unit', () => { + test('should count days between two dates', () => { + const result = count('2024-03-01T00:00:00Z', '2024-03-31T00:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result).toBe(30) + }) + + test('should count zero when same day', () => { + const result = count('2024-03-15T12:00:00Z', '2024-03-15T18:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result).toBe(0) + }) + + test('should count one day when crossing midnight', () => { + const result = count('2024-03-15T12:00:00Z', '2024-03-16T00:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result).toBe(1) + }) + }) + + describe('with month unit', () => { + test('should count months between two dates', () => { + const result = count('2024-01-15T00:00:00Z', '2024-05-01T00:00:00Z', { + unit: 'month', + timeZone: 'UTC', + }) + expect(result).toBe(4) + }) + + test('should count zero when same month', () => { + const result = count('2024-03-01T00:00:00Z', '2024-03-31T00:00:00Z', { + unit: 'month', + timeZone: 'UTC', + }) + expect(result).toBe(0) + }) + }) + + describe('with year unit', () => { + test('should count years between two dates', () => { + const result = count('2024-06-01T00:00:00Z', '2026-01-01T00:00:00Z', { + unit: 'year', + timeZone: 'UTC', + }) + expect(result).toBe(2) + }) + + test('should count zero when same year', () => { + const result = count('2024-01-01T00:00:00Z', '2024-12-31T00:00:00Z', { + unit: 'year', + timeZone: 'UTC', + }) + expect(result).toBe(0) + }) + }) + + describe('with hour unit', () => { + test('should count hours between two times', () => { + const result = count('2024-03-01T00:00:00Z', '2024-03-01T12:00:00Z', { + unit: 'hour', + timeZone: 'UTC', + }) + expect(result).toBe(12) + }) + }) + + describe('with week unit', () => { + test('should count weeks between two dates', () => { + const result = count('2024-03-01T00:00:00Z', '2024-03-29T00:00:00Z', { + unit: 'week', + timeZone: 'UTC', + }) + expect(result).toBe(4) + }) + }) + + describe('with different input types', () => { + test('should work with Date objects', () => { + const result = count( + new Date('2024-03-01T00:00:00Z'), + new Date('2024-03-31T00:00:00Z'), + { unit: 'day', timeZone: 'UTC' }, + ) + expect(result).toBe(30) + }) + + test('should work with epoch time', () => { + const start = new Date('2024-03-01T00:00:00Z').getTime() + const end = new Date('2024-03-31T00:00:00Z').getTime() + const result = count(start, end, { unit: 'day', timeZone: 'UTC' }) + expect(result).toBe(30) + }) + + test('should work with ZonedDateTime', () => { + const start = Temporal.ZonedDateTime.from( + '2024-03-01T00:00:00Z[UTC][u-ca=gregory]', + ) + const end = Temporal.ZonedDateTime.from( + '2024-03-31T00:00:00Z[UTC][u-ca=gregory]', + ) + const result = count(start, end, { unit: 'day', timeZone: 'UTC' }) + expect(result).toBe(30) + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = count('2024-03-01T00:00:00Z', '2024-03-31T00:00:00Z', { + unit: 'day', + timeZone: 'America/New_York', + }) + expect(result).toBe(30) + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = count('2024-03-01T00:00:00Z', '2024-03-31T00:00:00Z', { + unit: 'day', + timeZone: 'UTC', + calendar: 'japanese', + }) + expect(result).toBe(30) + }) + }) + + describe('edge cases', () => { + test('should handle leap year', () => { + const result = count('2024-02-01T00:00:00Z', '2024-03-01T00:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result).toBe(29) + }) + + test('should handle negative direction', () => { + const result = count('2024-03-31T00:00:00Z', '2024-03-01T00:00:00Z', { + unit: 'day', + timeZone: 'UTC', + }) + expect(result).toBe(-30) + }) + }) +}) diff --git a/packages/time/src/date/fromUnixTime/fromUnixTime.ts b/packages/time/src/date/fromUnixTime/fromUnixTime.ts new file mode 100644 index 00000000..5dd42269 --- /dev/null +++ b/packages/time/src/date/fromUnixTime/fromUnixTime.ts @@ -0,0 +1,22 @@ +import { Temporal } from '@js-temporal/polyfill' +import { createDateOperationResult } from '../withDateOperation' +import type { DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface FromUnixTimeOptions extends DateOptions {} + +export function fromUnixTime(timestamp: number, options?: FromUnixTimeOptions) { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + const instant = Temporal.Instant.fromEpochMilliseconds(timestamp * 1000) + const zdt = instant.toZonedDateTimeISO(timeZone).withCalendar(calendar) + + return createDateOperationResult(zdt, { + timeZone, + calendar, + returnFormat: 'standard', + }) +} diff --git a/packages/time/src/date/fromUnixTime/index.ts b/packages/time/src/date/fromUnixTime/index.ts new file mode 100644 index 00000000..61068848 --- /dev/null +++ b/packages/time/src/date/fromUnixTime/index.ts @@ -0,0 +1 @@ +export * from './fromUnixTime' diff --git a/packages/time/src/date/fromUnixTime/tests/fromUnixTime.test.ts b/packages/time/src/date/fromUnixTime/tests/fromUnixTime.test.ts new file mode 100644 index 00000000..84ed1b00 --- /dev/null +++ b/packages/time/src/date/fromUnixTime/tests/fromUnixTime.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest' +import { fromUnixTime } from '../fromUnixTime' + +describe('fromUnixTime', () => { + describe('with unix timestamp', () => { + test('should convert unix timestamp to date', () => { + const timestamp = 1710513732 + const result = fromUnixTime(timestamp, { timeZone: 'UTC' }) + expect(result.value).toBe('2024-03-15T14:42:12Z') + }) + + test('should convert zero timestamp', () => { + const result = fromUnixTime(0, { timeZone: 'UTC' }) + expect(result.value).toBe('1970-01-01T00:00:00Z') + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const timestamp = 1710513732 + const result = fromUnixTime(timestamp, { + timeZone: 'America/New_York', + }) + expect(result.timeZone).toBe('America/New_York') + expect(result.value).toContain('2024-03-15') + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const timestamp = 1710513732 + const result = fromUnixTime(timestamp, { calendar: 'japanese' }) + expect(result.calendar).toBe('japanese') + }) + }) + + describe('output methods', () => { + test('asDate should return Date object', () => { + const timestamp = 1710513732 + const result = fromUnixTime(timestamp, { timeZone: 'UTC' }) + const date = result.asDate() + expect(date).toBeInstanceOf(Date) + expect(date.toISOString()).toBe('2024-03-15T14:42:12.000Z') + }) + + test('asEpoch should return epoch timestamp', () => { + const timestamp = 1710513732 + const result = fromUnixTime(timestamp, { timeZone: 'UTC' }) + expect(result.asEpoch()).toBe(1710513732000) + }) + }) +}) diff --git a/packages/time/src/date/getDayOfYear/getDayOfYear.ts b/packages/time/src/date/getDayOfYear/getDayOfYear.ts new file mode 100644 index 00000000..d630d2b7 --- /dev/null +++ b/packages/time/src/date/getDayOfYear/getDayOfYear.ts @@ -0,0 +1,18 @@ +import { toZonedDateTime } from '../helpers' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface GetDayOfYearOptions extends DateOptions {} + +export function getDayOfYear( + date: DateInput, + options?: GetDayOfYearOptions, +): number { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + const zdt = toZonedDateTime(date, timeZone, calendar) + return zdt.dayOfYear +} diff --git a/packages/time/src/date/getDayOfYear/index.ts b/packages/time/src/date/getDayOfYear/index.ts new file mode 100644 index 00000000..a440dcfe --- /dev/null +++ b/packages/time/src/date/getDayOfYear/index.ts @@ -0,0 +1 @@ +export * from './getDayOfYear' diff --git a/packages/time/src/date/getDayOfYear/tests/getDayOfYear.test.ts b/packages/time/src/date/getDayOfYear/tests/getDayOfYear.test.ts new file mode 100644 index 00000000..a8d145d0 --- /dev/null +++ b/packages/time/src/date/getDayOfYear/tests/getDayOfYear.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { getDayOfYear } from '../getDayOfYear' + +describe('getDayOfYear', () => { + describe('with string input', () => { + test('should return day of year for January 1st', () => { + const result = getDayOfYear('2024-01-01T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(1) + }) + + test('should return day of year for March 15th', () => { + const result = getDayOfYear('2024-03-15T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(75) + }) + + test('should return day of year for December 31st in leap year', () => { + const result = getDayOfYear('2024-12-31T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(366) + }) + + test('should return day of year for December 31st in non-leap year', () => { + const result = getDayOfYear('2023-12-31T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(365) + }) + }) + + describe('with different input types', () => { + test('should work with Date object', () => { + const result = getDayOfYear(new Date('2024-03-15T00:00:00Z'), { + timeZone: 'UTC', + }) + expect(result).toBe(75) + }) + + test('should work with epoch time', () => { + const epoch = new Date('2024-03-15T00:00:00Z').getTime() + const result = getDayOfYear(epoch, { timeZone: 'UTC' }) + expect(result).toBe(75) + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2024-03-15T00:00:00Z[UTC][u-ca=gregory]', + ) + const result = getDayOfYear(zdt, { timeZone: 'UTC' }) + expect(result).toBe(75) + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = getDayOfYear('2024-03-15T00:00:00Z', { + timeZone: 'America/New_York', + }) + expect(result).toBe(74) + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = getDayOfYear('2024-03-15T00:00:00Z', { + timeZone: 'UTC', + calendar: 'japanese', + }) + expect(result).toBe(75) + }) + }) +}) diff --git a/packages/time/src/date/getUnixTime/getUnixTime.ts b/packages/time/src/date/getUnixTime/getUnixTime.ts new file mode 100644 index 00000000..e6edb5b6 --- /dev/null +++ b/packages/time/src/date/getUnixTime/getUnixTime.ts @@ -0,0 +1,18 @@ +import { toZonedDateTime } from '../helpers' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface GetUnixTimeOptions extends DateOptions {} + +export function getUnixTime( + date: DateInput, + options?: GetUnixTimeOptions, +): number { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + const zdt = toZonedDateTime(date, timeZone, calendar) + return Number(zdt.epochNanoseconds / 1_000_000_000n) +} diff --git a/packages/time/src/date/getUnixTime/index.ts b/packages/time/src/date/getUnixTime/index.ts new file mode 100644 index 00000000..0c70625c --- /dev/null +++ b/packages/time/src/date/getUnixTime/index.ts @@ -0,0 +1 @@ +export * from './getUnixTime' diff --git a/packages/time/src/date/getUnixTime/tests/getUnixTime.test.ts b/packages/time/src/date/getUnixTime/tests/getUnixTime.test.ts new file mode 100644 index 00000000..206a604f --- /dev/null +++ b/packages/time/src/date/getUnixTime/tests/getUnixTime.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { getUnixTime } from '../getUnixTime' + +describe('getUnixTime', () => { + describe('with string input', () => { + test('should return unix timestamp for date', () => { + const result = getUnixTime('2024-03-15T14:42:12Z', { timeZone: 'UTC' }) + expect(result).toBe(1710513732) + }) + + test('should return zero for epoch', () => { + const result = getUnixTime('1970-01-01T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(0) + }) + }) + + describe('with different input types', () => { + test('should work with Date object', () => { + const result = getUnixTime(new Date('2024-03-15T14:42:12Z'), { + timeZone: 'UTC', + }) + expect(result).toBe(1710513732) + }) + + test('should work with epoch time', () => { + const epoch = new Date('2024-03-15T14:42:12Z').getTime() + const result = getUnixTime(epoch, { timeZone: 'UTC' }) + expect(result).toBe(1710513732) + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2024-03-15T14:42:12Z[UTC][u-ca=gregory]', + ) + const result = getUnixTime(zdt, { timeZone: 'UTC' }) + expect(result).toBe(1710513732) + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = getUnixTime('2024-03-15T14:42:12Z', { + timeZone: 'America/New_York', + }) + expect(result).toBe(1710513732) + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = getUnixTime('2024-03-15T14:42:12Z', { + timeZone: 'UTC', + calendar: 'japanese', + }) + expect(result).toBe(1710513732) + }) + }) +}) diff --git a/packages/time/src/date/getWeek/getWeek.ts b/packages/time/src/date/getWeek/getWeek.ts new file mode 100644 index 00000000..af02b675 --- /dev/null +++ b/packages/time/src/date/getWeek/getWeek.ts @@ -0,0 +1,15 @@ +import { toZonedDateTime } from '../helpers' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface GetWeekOptions extends DateOptions {} + +export function getWeek(date: DateInput, options?: GetWeekOptions): number { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + const zdt = toZonedDateTime(date, timeZone, calendar) + return zdt.weekOfYear ?? 0 +} diff --git a/packages/time/src/date/getWeek/index.ts b/packages/time/src/date/getWeek/index.ts new file mode 100644 index 00000000..dd153c24 --- /dev/null +++ b/packages/time/src/date/getWeek/index.ts @@ -0,0 +1 @@ +export * from './getWeek' diff --git a/packages/time/src/date/getWeek/tests/getWeek.test.ts b/packages/time/src/date/getWeek/tests/getWeek.test.ts new file mode 100644 index 00000000..07403377 --- /dev/null +++ b/packages/time/src/date/getWeek/tests/getWeek.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { getWeek } from '../getWeek' + +describe('getWeek', () => { + describe('with string input', () => { + test('should return week for January 1st', () => { + const result = getWeek('2024-01-01T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(1) + }) + + test('should return week for March 15th', () => { + const result = getWeek('2024-03-15T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(11) + }) + + test('should return week for December 31st', () => { + const result = getWeek('2024-12-31T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(1) + }) + }) + + describe('with different input types', () => { + test('should work with Date object', () => { + const result = getWeek(new Date('2024-03-15T00:00:00Z'), { + timeZone: 'UTC', + }) + expect(result).toBe(11) + }) + + test('should work with epoch time', () => { + const epoch = new Date('2024-03-15T00:00:00Z').getTime() + const result = getWeek(epoch, { timeZone: 'UTC' }) + expect(result).toBe(11) + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2024-03-15T00:00:00Z[UTC][u-ca=gregory]', + ) + const result = getWeek(zdt, { timeZone: 'UTC' }) + expect(result).toBe(11) + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = getWeek('2024-03-15T00:00:00Z', { + timeZone: 'America/New_York', + }) + expect(result).toBe(11) + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = getWeek('2024-03-15T00:00:00Z', { + timeZone: 'UTC', + calendar: 'gregory', + }) + expect(result).toBe(11) + }) + }) +}) diff --git a/packages/time/src/date/index.ts b/packages/time/src/date/index.ts index c2c04af8..4db658a1 100644 --- a/packages/time/src/date/index.ts +++ b/packages/time/src/date/index.ts @@ -23,3 +23,15 @@ export * from './format' export * from './isLeapYear' export * from './isWeekend' export * from './range' +export * from './ceil' +export * from './clamp' +export * from './set' +export * from './count' +export * from './min' +export * from './max' +export * from './isPast' +export * from './isFuture' +export * from './fromUnixTime' +export * from './getUnixTime' +export * from './getDayOfYear' +export * from './getWeek' diff --git a/packages/time/src/date/isFuture/index.ts b/packages/time/src/date/isFuture/index.ts new file mode 100644 index 00000000..af5644e7 --- /dev/null +++ b/packages/time/src/date/isFuture/index.ts @@ -0,0 +1 @@ +export * from './isFuture' diff --git a/packages/time/src/date/isFuture/isFuture.ts b/packages/time/src/date/isFuture/isFuture.ts new file mode 100644 index 00000000..9b07e99b --- /dev/null +++ b/packages/time/src/date/isFuture/isFuture.ts @@ -0,0 +1,18 @@ +import { Temporal } from '@js-temporal/polyfill' +import { toZonedDateTime } from '../helpers' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface IsFutureOptions extends DateOptions {} + +export function isFuture(date: DateInput, options?: IsFutureOptions): boolean { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + const zdt = toZonedDateTime(date, timeZone, calendar) + const now = Temporal.Now.zonedDateTimeISO(timeZone).withCalendar(calendar) + + return Temporal.ZonedDateTime.compare(zdt, now) > 0 +} diff --git a/packages/time/src/date/isFuture/tests/isFuture.test.ts b/packages/time/src/date/isFuture/tests/isFuture.test.ts new file mode 100644 index 00000000..b14df57f --- /dev/null +++ b/packages/time/src/date/isFuture/tests/isFuture.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { isFuture } from '../isFuture' + +describe('isFuture', () => { + describe('with string input', () => { + test('should return true for future date', () => { + const result = isFuture('2030-01-01T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(true) + }) + + test('should return false for past date', () => { + const result = isFuture('2020-01-01T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(false) + }) + }) + + describe('with different input types', () => { + test('should work with Date object', () => { + const result = isFuture(new Date('2030-01-01T00:00:00Z'), { + timeZone: 'UTC', + }) + expect(result).toBe(true) + }) + + test('should work with epoch time', () => { + const epoch = new Date('2030-01-01T00:00:00Z').getTime() + const result = isFuture(epoch, { timeZone: 'UTC' }) + expect(result).toBe(true) + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2030-01-01T00:00:00Z[UTC][u-ca=gregory]', + ) + const result = isFuture(zdt, { timeZone: 'UTC' }) + expect(result).toBe(true) + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = isFuture('2030-01-01T00:00:00Z', { + timeZone: 'America/New_York', + }) + expect(result).toBe(true) + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = isFuture('2030-01-01T00:00:00Z', { + timeZone: 'UTC', + calendar: 'japanese', + }) + expect(result).toBe(true) + }) + }) +}) diff --git a/packages/time/src/date/isPast/index.ts b/packages/time/src/date/isPast/index.ts new file mode 100644 index 00000000..583c866e --- /dev/null +++ b/packages/time/src/date/isPast/index.ts @@ -0,0 +1 @@ +export * from './isPast' diff --git a/packages/time/src/date/isPast/isPast.ts b/packages/time/src/date/isPast/isPast.ts new file mode 100644 index 00000000..59cbae9a --- /dev/null +++ b/packages/time/src/date/isPast/isPast.ts @@ -0,0 +1,18 @@ +import { Temporal } from '@js-temporal/polyfill' +import { toZonedDateTime } from '../helpers' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface IsPastOptions extends DateOptions {} + +export function isPast(date: DateInput, options?: IsPastOptions): boolean { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + const zdt = toZonedDateTime(date, timeZone, calendar) + const now = Temporal.Now.zonedDateTimeISO(timeZone).withCalendar(calendar) + + return Temporal.ZonedDateTime.compare(zdt, now) < 0 +} diff --git a/packages/time/src/date/isPast/tests/isPast.test.ts b/packages/time/src/date/isPast/tests/isPast.test.ts new file mode 100644 index 00000000..df4df486 --- /dev/null +++ b/packages/time/src/date/isPast/tests/isPast.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { isPast } from '../isPast' + +describe('isPast', () => { + describe('with string input', () => { + test('should return true for past date', () => { + const result = isPast('2020-01-01T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(true) + }) + + test('should return false for future date', () => { + const result = isPast('2030-01-01T00:00:00Z', { timeZone: 'UTC' }) + expect(result).toBe(false) + }) + }) + + describe('with different input types', () => { + test('should work with Date object', () => { + const result = isPast(new Date('2020-01-01T00:00:00Z'), { + timeZone: 'UTC', + }) + expect(result).toBe(true) + }) + + test('should work with epoch time', () => { + const epoch = new Date('2020-01-01T00:00:00Z').getTime() + const result = isPast(epoch, { timeZone: 'UTC' }) + expect(result).toBe(true) + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2020-01-01T00:00:00Z[UTC][u-ca=gregory]', + ) + const result = isPast(zdt, { timeZone: 'UTC' }) + expect(result).toBe(true) + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = isPast('2020-01-01T00:00:00Z', { + timeZone: 'America/New_York', + }) + expect(result).toBe(true) + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = isPast('2020-01-01T00:00:00Z', { + timeZone: 'UTC', + calendar: 'japanese', + }) + expect(result).toBe(true) + }) + }) +}) diff --git a/packages/time/src/date/max/index.ts b/packages/time/src/date/max/index.ts new file mode 100644 index 00000000..82263ff2 --- /dev/null +++ b/packages/time/src/date/max/index.ts @@ -0,0 +1 @@ +export * from './max' diff --git a/packages/time/src/date/max/max.ts b/packages/time/src/date/max/max.ts new file mode 100644 index 00000000..685b31ea --- /dev/null +++ b/packages/time/src/date/max/max.ts @@ -0,0 +1,33 @@ +import { Temporal } from '@js-temporal/polyfill' +import { toZonedDateTime } from '../helpers' +import { createDateOperationResult } from '../withDateOperation' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface MaxOptions extends DateOptions {} + +export function max(dates: Array, options?: MaxOptions) { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + if (dates.length === 0) { + throw new Error('max requires at least one date') + } + + let maxZdt = toZonedDateTime(dates[0]!, timeZone, calendar) + + for (let i = 1; i < dates.length; i++) { + const zdt = toZonedDateTime(dates[i]!, timeZone, calendar) + if (Temporal.ZonedDateTime.compare(zdt, maxZdt) > 0) { + maxZdt = zdt + } + } + + return createDateOperationResult(maxZdt, { + timeZone, + calendar, + returnFormat: 'standard', + }) +} diff --git a/packages/time/src/date/max/tests/max.test.ts b/packages/time/src/date/max/tests/max.test.ts new file mode 100644 index 00000000..8bf7a826 --- /dev/null +++ b/packages/time/src/date/max/tests/max.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { max } from '../max' + +describe('max', () => { + describe('with string inputs', () => { + test('should return latest date', () => { + const result = max( + [ + '2024-03-15T00:00:00Z', + '2024-03-01T00:00:00Z', + '2024-03-31T00:00:00Z', + ], + { timeZone: 'UTC' }, + ) + expect(result.value).toBe('2024-03-31T00:00:00Z') + }) + + test('should work with single date', () => { + const result = max(['2024-03-15T00:00:00Z'], { timeZone: 'UTC' }) + expect(result.value).toBe('2024-03-15T00:00:00Z') + }) + + test('should work with two dates', () => { + const result = max(['2024-03-31T00:00:00Z', '2024-03-01T00:00:00Z'], { + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-31T00:00:00Z') + }) + }) + + describe('with mixed input types', () => { + test('should work with mixed Date, string, and epoch', () => { + const result = max( + [ + '2024-03-15T00:00:00Z', + new Date('2024-03-01T00:00:00Z'), + new Date('2024-03-31T00:00:00Z').getTime(), + ], + { timeZone: 'UTC' }, + ) + expect(result.value).toBe('2024-03-31T00:00:00Z') + }) + + test('should work with ZonedDateTime', () => { + const result = max( + [ + Temporal.ZonedDateTime.from( + '2024-03-15T00:00:00Z[UTC][u-ca=gregory]', + ), + Temporal.ZonedDateTime.from( + '2024-03-31T00:00:00Z[UTC][u-ca=gregory]', + ), + ], + { timeZone: 'UTC' }, + ) + expect(result.value).toBe('2024-03-31T00:00:00Z') + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = max(['2024-03-15T00:00:00Z', '2024-03-01T00:00:00Z'], { + timeZone: 'America/New_York', + }) + expect(result.timeZone).toBe('America/New_York') + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = max(['2024-03-15T00:00:00Z', '2024-03-01T00:00:00Z'], { + calendar: 'japanese', + }) + expect(result.calendar).toBe('japanese') + }) + }) + + describe('edge cases', () => { + test('should throw on empty array', () => { + expect(() => max([], { timeZone: 'UTC' })).toThrow( + 'max requires at least one date', + ) + }) + }) +}) diff --git a/packages/time/src/date/min/index.ts b/packages/time/src/date/min/index.ts new file mode 100644 index 00000000..1bec140a --- /dev/null +++ b/packages/time/src/date/min/index.ts @@ -0,0 +1 @@ +export * from './min' diff --git a/packages/time/src/date/min/min.ts b/packages/time/src/date/min/min.ts new file mode 100644 index 00000000..751bd37b --- /dev/null +++ b/packages/time/src/date/min/min.ts @@ -0,0 +1,33 @@ +import { Temporal } from '@js-temporal/polyfill' +import { toZonedDateTime } from '../helpers' +import { createDateOperationResult } from '../withDateOperation' +import type { DateInput, DateOptions } from '../types' +import { getDateTimeDefaults } from '~/utils' + +export interface MinOptions extends DateOptions {} + +export function min(dates: Array, options?: MinOptions) { + const { timeZone: defaultTimeZone, calendar: defaultCalendar } = + getDateTimeDefaults() + const { timeZone = defaultTimeZone, calendar = defaultCalendar } = + options ?? {} + + if (dates.length === 0) { + throw new Error('min requires at least one date') + } + + let minZdt = toZonedDateTime(dates[0]!, timeZone, calendar) + + for (let i = 1; i < dates.length; i++) { + const zdt = toZonedDateTime(dates[i]!, timeZone, calendar) + if (Temporal.ZonedDateTime.compare(zdt, minZdt) < 0) { + minZdt = zdt + } + } + + return createDateOperationResult(minZdt, { + timeZone, + calendar, + returnFormat: 'standard', + }) +} diff --git a/packages/time/src/date/min/tests/min.test.ts b/packages/time/src/date/min/tests/min.test.ts new file mode 100644 index 00000000..5d12b86c --- /dev/null +++ b/packages/time/src/date/min/tests/min.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { min } from '../min' + +describe('min', () => { + describe('with string inputs', () => { + test('should return earliest date', () => { + const result = min( + [ + '2024-03-15T00:00:00Z', + '2024-03-01T00:00:00Z', + '2024-03-31T00:00:00Z', + ], + { timeZone: 'UTC' }, + ) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + + test('should work with single date', () => { + const result = min(['2024-03-15T00:00:00Z'], { timeZone: 'UTC' }) + expect(result.value).toBe('2024-03-15T00:00:00Z') + }) + + test('should work with two dates', () => { + const result = min(['2024-03-31T00:00:00Z', '2024-03-01T00:00:00Z'], { + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + }) + + describe('with mixed input types', () => { + test('should work with mixed Date, string, and epoch', () => { + const result = min( + [ + '2024-03-15T00:00:00Z', + new Date('2024-03-01T00:00:00Z'), + new Date('2024-03-31T00:00:00Z').getTime(), + ], + { timeZone: 'UTC' }, + ) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + + test('should work with ZonedDateTime', () => { + const result = min( + [ + Temporal.ZonedDateTime.from( + '2024-03-15T00:00:00Z[UTC][u-ca=gregory]', + ), + Temporal.ZonedDateTime.from( + '2024-03-01T00:00:00Z[UTC][u-ca=gregory]', + ), + ], + { timeZone: 'UTC' }, + ) + expect(result.value).toBe('2024-03-01T00:00:00Z') + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = min(['2024-03-15T00:00:00Z', '2024-03-01T00:00:00Z'], { + timeZone: 'America/New_York', + }) + expect(result.timeZone).toBe('America/New_York') + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = min(['2024-03-15T00:00:00Z', '2024-03-01T00:00:00Z'], { + calendar: 'japanese', + }) + expect(result.calendar).toBe('japanese') + }) + }) + + describe('edge cases', () => { + test('should throw on empty array', () => { + expect(() => min([], { timeZone: 'UTC' })).toThrow( + 'min requires at least one date', + ) + }) + }) +}) diff --git a/packages/time/src/date/set/index.ts b/packages/time/src/date/set/index.ts new file mode 100644 index 00000000..ee663561 --- /dev/null +++ b/packages/time/src/date/set/index.ts @@ -0,0 +1 @@ +export * from './set' diff --git a/packages/time/src/date/set/set.ts b/packages/time/src/date/set/set.ts new file mode 100644 index 00000000..699504ec --- /dev/null +++ b/packages/time/src/date/set/set.ts @@ -0,0 +1,25 @@ +import { withDateOperation } from '../withDateOperation' +import type { DateOperationOptions } from '../withDateOperation' +import type { DateInput } from '../types' + +export interface SetFields { + year?: number + month?: number + day?: number + hour?: number + minute?: number + second?: number + millisecond?: number + microsecond?: number + nanosecond?: number +} + +export interface SetOptions extends DateOperationOptions { + fields: SetFields +} + +export function set(input: DateInput, options: SetOptions) { + return withDateOperation((zdt, { fields }) => { + return zdt.with(fields) + })(input, options) +} diff --git a/packages/time/src/date/set/tests/set.test.ts b/packages/time/src/date/set/tests/set.test.ts new file mode 100644 index 00000000..bfc732ae --- /dev/null +++ b/packages/time/src/date/set/tests/set.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from 'vitest' +import { Temporal } from '@js-temporal/polyfill' +import { set } from '../set' + +describe('set', () => { + describe('with string input', () => { + test('should set year', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { year: 2025 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2025-03-15T14:42:12Z') + }) + + test('should set month', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { month: 5 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-05-15T14:42:12Z') + }) + + test('should set day', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { day: 1 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-01T14:42:12Z') + }) + + test('should set hour', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { hour: 9 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T09:42:12Z') + }) + + test('should set minute', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { minute: 30 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T14:30:12Z') + }) + + test('should set second', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { second: 45 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T14:42:45Z') + }) + + test('should set millisecond', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { millisecond: 500 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-03-15T14:42:12.5Z') + }) + + test('should set multiple fields', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { year: 2025, month: 6, day: 1 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2025-06-01T14:42:12Z') + }) + }) + + describe('edge cases', () => { + test('should handle month end correctly', () => { + const result = set('2024-01-31T00:00:00Z', { + fields: { month: 2 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2024-02-29T00:00:00Z') + }) + + test('should handle leap year correctly', () => { + const result = set('2024-02-29T00:00:00Z', { + fields: { year: 2025 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2025-02-28T00:00:00Z') + }) + }) + + describe('with different input types', () => { + test('should work with Date objects', () => { + const date = new Date('2024-03-15T14:42:12Z') + const result = set(date, { + fields: { year: 2025 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2025-03-15T14:42:12Z') + }) + + test('should work with epoch time', () => { + const epoch = new Date('2024-03-15T14:42:12Z').getTime() + const result = set(epoch, { + fields: { year: 2025 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2025-03-15T14:42:12Z') + }) + + test('should work with ZonedDateTime', () => { + const zdt = Temporal.ZonedDateTime.from( + '2024-03-15T14:42:12Z[UTC][u-ca=gregory]', + ) + const result = set(zdt, { + fields: { year: 2025 }, + timeZone: 'UTC', + }) + expect(result.value).toBe('2025-03-15T14:42:12Z') + }) + }) + + describe('timezone handling', () => { + test('should respect timezone', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { hour: 9 }, + timeZone: 'America/New_York', + }) + expect(result.timeZone).toBe('America/New_York') + }) + }) + + describe('calendar handling', () => { + test('should respect calendar', () => { + const result = set('2024-03-15T14:42:12Z', { + fields: { year: 2025 }, + calendar: 'japanese', + }) + expect(result.calendar).toBe('japanese') + }) + }) +})