@burglekitt/gmt
Version:
Temporal-based date and time utilities with timezone support and polyfill integration
57 lines (56 loc) • 3.41 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import { cycleFieldValue, dateCycleFieldBounds } from "../../internal/index.js";
import { isValidDate, isValidDateCycleField } from "../validate/index.js";
import { setDate } from "./setDate.js";
/**
* Return a PlainDate ISO string with `field` cycled by `amount`, wrapping at that field's own
* min/max instead of carrying into the next larger field.
*
* - `cycleDate` is not `addDate`: cycling December's `month` by `+1` stays in the same year
* (`"2024-12-15"` → `"2024-01-15"`), where `addDate(value, { months: 1 })` would correctly
* overflow into January of the *next* year. Reach for `addDate` when you want calendar
* arithmetic; reach for `cycleDate` when a single field (e.g. a datepicker segment) must stay
* isolated from the others.
* - `year` has no upper/lower wrap — cycling it is plain addition (or rounding, see below).
* `month` wraps `1–12`. `day` wraps `1`–the **current** month's day count, so cycling `day`
* never changes `month`.
* - Cycling `month` or `year` can still shift `day` via `overflow` — e.g. cycling `month` from a
* 31st into a shorter month clamps under the default `"constrain"` (or returns `""` under
* `"reject"`) exactly the way `setDate`'s own `.with()` call does; this is the same clamping
* `addDate`'s Jan 31 + 1 month case produces, not new behavior.
* - `options.round` does **not** round to the nearest increment — it steps to the *next* multiple
* of `amount` in the direction of its sign (ceiling for positive, floor for negative), matching
* `@internationalized/date`'s `CycleOptions.round`. E.g. cycling `year` `2022` by `+5` with
* `round: true` lands on `2025` (the next multiple of 5 above 2022), not `2020` (the nearest
* multiple).
* - Returns "" for an invalid `value` or an invalid `field`.
*
* @param value ISO PlainDate string
* @param field the field to cycle: "year" | "month" | "day"
* @param amount signed amount to cycle by
* @param options optional: round (boolean, default false), overflow ("constrain" | "reject")
* @returns ISO PlainDate string with `field` cycled, or "" on invalid input
*
* @example cycleDate("2024-06-15", "month", 1) // "2024-07-15"
* @example cycleDate("2024-12-15", "month", 1) // "2024-01-15" (wraps, stays in the same year)
* @example cycleDate("2024-12-31", "day", 1) // "2024-12-01" (wraps within the same month)
* @example cycleDate("2024-01-15", "month", 13) // "2024-02-15" (amount larger than the range)
* @example cycleDate("2024-01-31", "month", 1) // "2024-02-29" (constrain clamps the day)
* @example cycleDate("2024-01-31", "month", 1, { overflow: "reject" }) // ""
* @example cycleDate("2022-02-03", "year", 5, { round: true }) // "2025-02-03"
* @example cycleDate("2024-06-15", "week", 1) // "" ("week" is not a cyclable date field)
* @example cycleDate("invalid", "month", 1) // ""
*/
export function cycleDate(value, field, amount, options) {
if (!isValidDate(value) || !isValidDateCycleField(field))
return "";
try {
const date = Temporal.PlainDate.from(value);
const bounds = dateCycleFieldBounds(field, date);
const newValue = cycleFieldValue(date[field], amount, bounds, options?.round ?? false);
return setDate(value, { [field]: newValue }, { overflow: options?.overflow });
}
catch {
return "";
}
}