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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions docs/adr/0005-v1-scope-calendar-product-react-only.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# v1 scope: Calendar product, React-supported, much deferred
# v1 scope: Calendar product, React-supported, some deferred

## Status

Expand All @@ -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:**
Expand All @@ -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.
Expand All @@ -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).
Expand All @@ -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).
22 changes: 22 additions & 0 deletions packages/time/src/date/ceil/ceil.ts
Original file line number Diff line number Diff line change
@@ -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<CeilOptions>((zdt, { unit }) => {
return zdt.round({ smallestUnit: unit, roundingMode: 'ceil' })
})(input, options)
}
1 change: 1 addition & 0 deletions packages/time/src/date/ceil/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ceil'
141 changes: 141 additions & 0 deletions packages/time/src/date/ceil/tests/ceil.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
29 changes: 29 additions & 0 deletions packages/time/src/date/clamp/clamp.ts
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions packages/time/src/date/clamp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './clamp'
Loading
Loading