Current section
Files
Jump to
Current section
Files
src/calendar/date.gleam
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2021 The Elixir Team
// SPDX-FileCopyrightText: 2012 Plataformatec
import calendar/duration as calendar_duration
import calendar/iso
import gleam/int
import gleam/list
import gleam/order
import gleam/string
@external(erlang, "os", "system_time")
@external(javascript, "../os_ffi.mjs", "system_time")
fn system_time_seconds() -> Int
/// A Date struct and functions.
///
/// The Date struct contains the fields year, month, day and calendar.
/// New dates can be built with the `new` function.
pub type Date {
Date(year: Int, month: Int, day: Int, calendar: String)
}
pub type DateError {
InvalidDate
InvalidYear
InvalidMonth
InvalidDay
InvalidFormat
InvalidCalendar
IncompatibleCalendars
}
pub type DateFormat {
Extended
Basic
}
/// Creates a new Date struct.
pub fn new(
year: Int,
month: Int,
day: Int,
calendar: String,
) -> Result(Date, DateError) {
case is_valid_date(year, month, day) {
True -> Ok(Date(year: year, month: month, day: day, calendar: calendar))
False -> Error(InvalidDate)
}
}
/// Creates a new Date struct with ISO calendar as default.
pub fn new_simple(year: Int, month: Int, day: Int) -> Result(Date, DateError) {
new(year, month, day, "Calendar.ISO")
}
/// Creates a new Date struct with ISO calendar as default.
pub fn new_iso(year: Int, month: Int, day: Int) -> Result(Date, DateError) {
new(year, month, day, "Calendar.ISO")
}
/// Checks if a year is a leap year.
pub fn is_leap_year(year: Int) -> Bool {
case year % 4 == 0 {
True ->
case year % 100 == 0 {
True -> year % 400 == 0
False -> True
}
False -> False
}
}
/// Returns the number of days in a given month and year.
pub fn days_in_month(year: Int, month: Int) -> Int {
case month {
1 | 3 | 5 | 7 | 8 | 10 | 12 -> 31
4 | 6 | 9 | 11 -> 30
2 ->
case is_leap_year(year) {
True -> 29
False -> 28
}
_ -> 0
}
}
/// Validates if the given date components form a valid date.
fn is_valid_date(year: Int, month: Int, day: Int) -> Bool {
month >= 1 && month <= 12 && day >= 1 && day <= days_in_month(year, month)
}
/// Converts a Date to a string in ISO8601 format (YYYY-MM-DD).
pub fn to_string(date: Date) -> String {
let year_str = case date.year >= 0 && date.year <= 9999 {
True -> pad_left(int.to_string(date.year), 4, "0")
False -> int.to_string(date.year)
}
let month_str = pad_left(int.to_string(date.month), 2, "0")
let day_str = pad_left(int.to_string(date.day), 2, "0")
year_str <> "-" <> month_str <> "-" <> day_str
}
/// Helper function to pad string with leading characters.
fn pad_left(str: String, width: Int, pad_char: String) -> String {
let current_length = string.length(str)
case current_length >= width {
True -> str
False -> string.repeat(pad_char, width - current_length) <> str
}
}
/// Converts a Date to ISO8601 format.
pub fn to_iso8601(date: Date) -> String {
to_string(date)
}
/// Add days to a date.
pub fn add_days(date: Date, days: Int) -> Result(Date, DateError) {
let total_days = to_days_since_epoch(date) + days
from_days_since_epoch(total_days, date.calendar)
}
/// Subtract days from a date.
pub fn subtract_days(date: Date, days: Int) -> Result(Date, DateError) {
add_days(date, -days)
}
/// Compare two dates. Returns order.Lt, order.Eq, or order.Gt.
pub fn compare(date1: Date, date2: Date) -> order.Order {
let days1 = to_days_since_epoch(date1)
let days2 = to_days_since_epoch(date2)
int.compare(days1, days2)
}
/// Convert date to days since Unix epoch (1970-01-01).
fn to_days_since_epoch(date: Date) -> Int {
iso.date_to_iso_days(date.year, date.month, date.day) - 719_528
}
/// Convert days since epoch back to a date.
fn from_days_since_epoch(days: Int, calendar: String) -> Result(Date, DateError) {
// Use the proper ISO calendar implementation
// Convert from Unix epoch (1970-01-01) to ISO epoch (0000-01-01)
let iso_days = days + 719_528
// 719_528 is the offset from ISO epoch to Unix epoch
let #(year, month, day) = iso.date_from_iso_days(iso_days)
// Validate the result
case is_valid_date(year, month, day) {
True -> Ok(Date(year: year, month: month, day: day, calendar: calendar))
False -> Error(InvalidDate)
}
}
/// Convert month to approximate day of year.
fn month_to_day_of_year(year: Int, month: Int) -> Int {
case month {
1 -> 0
2 -> 31
3 -> 59 + leap_day_offset(year)
4 -> 90 + leap_day_offset(year)
5 -> 120 + leap_day_offset(year)
6 -> 151 + leap_day_offset(year)
7 -> 181 + leap_day_offset(year)
8 -> 212 + leap_day_offset(year)
9 -> 243 + leap_day_offset(year)
10 -> 273 + leap_day_offset(year)
11 -> 304 + leap_day_offset(year)
12 -> 334 + leap_day_offset(year)
_ -> 0
}
}
fn leap_day_offset(year: Int) -> Int {
case is_leap_year(year) {
True -> 1
False -> 0
}
}
/// Create a date from Unix timestamp (seconds since epoch).
pub fn from_timestamp(timestamp: Int) -> Result(Date, DateError) {
from_days_since_epoch(timestamp / 86_400, "Calendar.ISO")
}
/// Create a date from days since Unix epoch (1970-01-01).
pub fn from_days_since_unix_epoch(
days: Int,
calendar: String,
) -> Result(Date, DateError) {
from_days_since_epoch(days, calendar)
}
/// Convert date to Unix timestamp (approximation).
pub fn to_timestamp(date: Date) -> Int {
to_days_since_epoch(date) * 86_400
}
/// Returns the current date in UTC.
pub fn utc_today() -> Date {
utc_today_with_calendar("Calendar.ISO")
}
/// Returns the current date in UTC with specified calendar.
pub fn utc_today_with_calendar(calendar: String) -> Date {
let timestamp = system_time_seconds()
let days = timestamp / 86_400
case from_days_since_epoch(days, calendar) {
Ok(date) -> date
Error(_) -> Date(year: 1970, month: 1, day: 1, calendar: calendar)
}
}
/// Returns `true` if the year in the given `date` is a leap year.
pub fn leap_year(date: Date) -> Bool {
is_leap_year(date.year)
}
/// Returns the number of days in the given `date` month.
pub fn days_in_month_for_date(date: Date) -> Int {
days_in_month(date.year, date.month)
}
/// Returns the number of months in the given `date` year.
pub fn months_in_year(_date: Date) -> Int {
12
// Always 12 for ISO calendar
}
/// Parses an ISO 8601 date string.
pub fn from_iso8601(string: String) -> Result(Date, DateError) {
from_iso8601_with_calendar(string, "Calendar.ISO")
}
/// Parses an ISO 8601 date string with specified calendar.
pub fn from_iso8601_with_calendar(
string: String,
calendar: String,
) -> Result(Date, DateError) {
parse_iso8601_date(string, calendar)
}
/// Converts the given `date` to ISO 8601 with format option.
pub fn to_iso8601_with_format(date: Date, format: DateFormat) -> String {
case format {
Extended -> to_string(date)
Basic -> {
let year_str = case date.year >= 0 && date.year <= 9999 {
True -> pad_left(int.to_string(date.year), 4, "0")
False -> int.to_string(date.year)
}
let month_str = pad_left(int.to_string(date.month), 2, "0")
let day_str = pad_left(int.to_string(date.day), 2, "0")
year_str <> month_str <> day_str
}
}
}
/// Converts the given `date` to an Erlang date tuple.
pub fn to_erl(date: Date) -> #(Int, Int, Int) {
#(date.year, date.month, date.day)
}
/// Converts an Erlang date tuple to a `Date` struct.
pub fn from_erl(tuple: #(Int, Int, Int)) -> Result(Date, DateError) {
from_erl_with_calendar(tuple, "Calendar.ISO")
}
/// Converts an Erlang date tuple to a `Date` struct with specified calendar.
pub fn from_erl_with_calendar(
tuple: #(Int, Int, Int),
calendar: String,
) -> Result(Date, DateError) {
let #(year, month, day) = tuple
new(year, month, day, calendar)
}
/// Converts a number of gregorian days to a `Date` struct.
pub fn from_gregorian_days(days: Int) -> Date {
from_gregorian_days_with_calendar(days, "Calendar.ISO")
}
/// Converts a number of gregorian days to a `Date` struct with specified calendar.
pub fn from_gregorian_days_with_calendar(days: Int, calendar: String) -> Date {
case from_days_since_epoch(days - 719_163, calendar) {
// 719163 is gregorian epoch offset
Ok(date) -> date
Error(_) -> Date(year: 0, month: 1, day: 1, calendar: calendar)
}
}
/// Converts a `date` struct to a number of gregorian days.
pub fn to_gregorian_days(date: Date) -> Int {
to_days_since_epoch(date) + 719_163
// Add gregorian epoch offset
}
/// Returns `true` if the first date is strictly earlier than the second.
pub fn before(date1: Date, date2: Date) -> Bool {
case compare(date1, date2) {
order.Lt -> True
_ -> False
}
}
/// Returns `true` if the first date is strictly later than the second.
pub fn after(date1: Date, date2: Date) -> Bool {
case compare(date1, date2) {
order.Gt -> True
_ -> False
}
}
/// Converts the given `date` from its calendar to the given `calendar`.
pub fn convert(date: Date, target_calendar: String) -> Result(Date, DateError) {
case date.calendar == target_calendar {
True -> Ok(date)
False -> {
// Simple conversion - in a full implementation, this would check calendar compatibility
Ok(Date(
year: date.year,
month: date.month,
day: date.day,
calendar: target_calendar,
))
}
}
}
/// Adds the number of days to the given `date`.
pub fn add(date: Date, days: Int) -> Date {
case add_days(date, days) {
Ok(new_date) -> new_date
Error(_) -> date
// Fallback to original date
}
}
/// Calculates the difference between two dates, in a full number of days.
pub fn diff(date1: Date, date2: Date) -> Int {
to_days_since_epoch(date1) - to_days_since_epoch(date2)
}
/// Shifts given `date` by `duration` according to its calendar.
pub fn shift(
date: Date,
duration: calendar_duration.Duration,
) -> Result(Date, DateError) {
// Extract duration components
let years = duration.year
let months = duration.month
let weeks = duration.week
let days = duration.day
// Apply year and month shifts first
let new_year = date.year + years
let new_month = date.month + months
// Normalize month overflow
let normalized_year = new_year + { new_month - 1 } / 12
let normalized_month = { { new_month - 1 } % 12 } + 1
// Handle day overflow
let max_days = days_in_month(normalized_year, normalized_month)
let normalized_day = case date.day > max_days {
True -> max_days
False -> date.day
}
// Create intermediate date
case new(normalized_year, normalized_month, normalized_day, date.calendar) {
Ok(intermediate_date) -> {
// Add weeks and days
let total_days = weeks * 7 + days
add_days(intermediate_date, total_days)
}
Error(e) -> Error(e)
}
}
/// Calculates the ordinal day of the week of a given `date`.
pub fn day_of_week(date: Date) -> Int {
day_of_week_starting_on(date, 1)
// Monday = 1
}
/// Calculates the ordinal day of the week with custom starting day.
pub fn day_of_week_starting_on(date: Date, starting_on: Int) -> Int {
// Zeller's congruence for day of week calculation
let year = case date.month < 3 {
True -> date.year - 1
False -> date.year
}
let month = case date.month < 3 {
True -> date.month + 12
False -> date.month
}
let day_of_week =
{
date.day
+ { 13 * { month + 1 } }
/ 5
+ year
+ year
/ 4
- year
/ 100
+ year
/ 400
}
% 7
// Adjust for starting day (convert from 0=Saturday to 1=Monday standard)
let adjusted = case day_of_week {
0 -> 7
// Saturday -> 7
n -> n
}
// Rotate based on starting_on
case adjusted - starting_on + 1 {
n if n <= 0 -> n + 7
n if n > 7 -> n - 7
n -> n
}
}
/// Calculates a date that is the first day of the week for the given `date`.
pub fn beginning_of_week(date: Date) -> Date {
beginning_of_week_starting_on(date, 1)
// Monday
}
/// Calculates beginning of week with custom starting day.
pub fn beginning_of_week_starting_on(date: Date, starting_on: Int) -> Date {
let current_day_of_week = day_of_week_starting_on(date, starting_on)
let days_to_subtract = current_day_of_week - 1
add(date, -days_to_subtract)
}
/// Calculates a date that is the last day of the week for the given `date`.
pub fn end_of_week(date: Date) -> Date {
end_of_week_starting_on(date, 1)
// Monday
}
/// Calculates end of week with custom starting day.
pub fn end_of_week_starting_on(date: Date, starting_on: Int) -> Date {
let current_day_of_week = day_of_week_starting_on(date, starting_on)
let days_to_add = 7 - current_day_of_week
add(date, days_to_add)
}
/// Calculates the day of the year of a given `date`.
pub fn day_of_year(date: Date) -> Int {
month_to_day_of_year(date.year, date.month) + date.day
}
/// Calculates the quarter of the year of a given `date`.
pub fn quarter_of_year(date: Date) -> Int {
case date.month {
1 | 2 | 3 -> 1
4 | 5 | 6 -> 2
7 | 8 | 9 -> 3
10 | 11 | 12 -> 4
_ -> 1
}
}
/// Calculates the year-of-era and era for a given calendar year.
pub fn year_of_era(date: Date) -> #(Int, Int) {
case date.year >= 1 {
True -> #(date.year, 1)
// CE era
False -> #(-date.year + 1, 0)
// BCE era
}
}
/// Calculates the day-of-era and era for a given calendar `date`.
pub fn day_of_era(date: Date) -> #(Int, Int) {
let #(_, era) = year_of_era(date)
let days = to_gregorian_days(date)
case era {
1 -> #(days, 1)
// CE era
0 -> #(-days + 1, 0)
// BCE era
_ -> #(days, era)
}
}
/// Calculates a date that is the first day of the month for the given `date`.
pub fn beginning_of_month(date: Date) -> Date {
Date(year: date.year, month: date.month, day: 1, calendar: date.calendar)
}
/// Calculates a date that is the last day of the month for the given `date`.
pub fn end_of_month(date: Date) -> Date {
let last_day = days_in_month(date.year, date.month)
Date(
year: date.year,
month: date.month,
day: last_day,
calendar: date.calendar,
)
}
/// Creates a date range between two dates.
pub fn range(first: Date, last: Date) -> Result(List(Date), DateError) {
range_with_step(first, last, 1)
}
/// Creates a date range with a step.
pub fn range_with_step(
first: Date,
last: Date,
step: Int,
) -> Result(List(Date), DateError) {
case step == 0 {
True -> Error(InvalidDate)
False -> {
case first.calendar == last.calendar {
False -> Error(IncompatibleCalendars)
True -> {
let first_days = to_days_since_epoch(first)
let last_days = to_days_since_epoch(last)
generate_date_range(first, first_days, last_days, step, [])
}
}
}
}
}
// Helper functions
fn parse_iso8601_date(
string: String,
calendar: String,
) -> Result(Date, DateError) {
// Simple ISO8601 parser - expects YYYY-MM-DD format
case string.split(string, "-") {
[year_str, month_str, day_str] -> {
case int.parse(year_str), int.parse(month_str), int.parse(day_str) {
Ok(year), Ok(month), Ok(day) -> new(year, month, day, calendar)
_, _, _ -> Error(InvalidFormat)
}
}
_ -> Error(InvalidFormat)
}
}
fn generate_date_range(
current: Date,
current_days: Int,
target_days: Int,
step: Int,
acc: List(Date),
) -> Result(List(Date), DateError) {
case
step > 0
&& current_days > target_days
|| step < 0
&& current_days < target_days
{
True -> Ok(list.reverse(acc))
False -> {
let new_acc = [current, ..acc]
case from_days_since_epoch(current_days + step, current.calendar) {
Ok(next_date) ->
generate_date_range(
next_date,
current_days + step,
target_days,
step,
new_acc,
)
Error(e) -> Error(e)
}
}
}
}