Module:Date: Difference between revisions

    (major refactor with fixes; force Date to be read-only (error on write); list of dates in a month on a particular day of week)
    (rework date differences for more consistent years/months/days; differences include hours/minutes/seconds; can add 'date + diff'; tweaks)
    Line 3: Line 3:


    local MINUS = '−'  -- Unicode U+2212 MINUS SIGN
    local MINUS = '−'  -- Unicode U+2212 MINUS SIGN
    local floor = math.floor


    local Date, DateDiff, diffmt  -- forward declarations
    local Date, DateDiff, diffmt  -- forward declarations
    Line 34: Line 35:


    local function strip_to_nil(text)
    local function strip_to_nil(text)
    -- If text is a string, return its trimmed content, or nil.
    -- If text is a string, return its trimmed content, or nil if empty.
    -- Otherwise return text (convenient when Date fields are provided from
    -- Otherwise return text (convenient when Date fields are provided from
    -- another module which is able to pass, for example, a number).
    -- another module which may pass a string, a number, or another type).
    if type(text) == 'string' then
    if type(text) == 'string' then
    text = text:match('(%S.-)%s*$')
    text = text:match('(%S.-)%s*$')
    end
    end
    return text
    return text
    end
    local function number_name(number, singular, plural, sep)
    -- Return the given number, converted to a string, with the
    -- separator (default space) and singular or plural name appended.
    plural = plural or (singular .. 's')
    sep = sep or ' '
    return tostring(number) .. sep .. ((number == 1) and singular or plural)
    end
    end


    Line 65: Line 58:
    end
    end
    return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })[month]
    return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })[month]
    end
    local function h_m_s(time)
    -- Return hour, minute, second extracted from fraction of a day.
    time = floor(time * 24 * 3600 + 0.5)  -- number of seconds
    local second = time % 60
    time = floor(time / 60)
    return floor(time / 60), time % 60, second
    end
    local function hms(date)
    -- Return fraction of a day (0 <= fraction < 1) from date's time.
    return (date.hour + (date.minute + date.second / 60) / 60) / 24
    end
    end


    Line 76: Line 82:
    --    1 January 4713 BC  = (-4712, 1, 1)  Julian calendar
    --    1 January 4713 BC  = (-4712, 1, 1)  Julian calendar
    --  24 November 4714 BC = (-4713, 11, 24) Gregorian calendar
    --  24 November 4714 BC = (-4713, 11, 24) Gregorian calendar
    local floor = math.floor
    local offset
    local offset
    local a = floor((14 - date.month)/12)
    local a = floor((14 - date.month)/12)
    Line 88: Line 93:
    local jd = date.day + floor((153*m + 2)/5) + 365*y + offset
    local jd = date.day + floor((153*m + 2)/5) + 365*y + offset
    if date.hastime then
    if date.hastime then
    jd = jd + (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
    jd = jd + hms(date) - 0.5
    return jd, jd
    return jd, jd
    end
    end
    Line 100: Line 105:
    -- This handles the proleptic Julian and Gregorian calendars.
    -- This handles the proleptic Julian and Gregorian calendars.
    -- Negative Julian dates are not defined but they work.
    -- Negative Julian dates are not defined but they work.
    local floor = math.floor
    local calname = date.calname
    local calname = date.calname
    local limits -- min/max limits for date ranges −9999-01-01 to 9999-12-31
    local low, high -- min/max limits for date ranges −9999-01-01 to 9999-12-31
    if calname == 'Julian' then
    if calname == 'Gregorian' then
    limits = { -1931076.5, 5373557.49999 }
    low, high = -1930999.5, 5373484.49999
    elseif calname == 'Gregorian' then
    elseif calname == 'Julian' then
    limits = { -1930999.5, 5373484.49999 }
    low, high = -1931076.5, 5373557.49999
    else
    else
    return
    return
    end
    end
    local jd = date.jd
    local jd = date.jd
    if not (type(jd) == 'number' and limits[1] <= jd and jd <= limits[2]) then
    if not (type(jd) == 'number' and low <= jd and jd <= high) then
    return
    return
    end
    end
    local jdn = floor(jd)
    local jdn = floor(jd)
    if date.hastime then
    if date.hastime then
    local time = jd - jdn
    local time = jd - jdn -- 0 <= time < 1
    local hour
    if time >= 0.5 then   -- if at or after midnight of next day
    if time >= 0.5 then
    jdn = jdn + 1
    jdn = jdn + 1
    time = time - 0.5
    time = time - 0.5
    hour = 0
    else
    else
    hour = 12
    time = time + 0.5
    end
    end
    time = floor(time * 24 * 3600 + 0.5)  -- number of seconds after hour
    date.hour, date.minute, date.second = h_m_s(time)
    date.second = time % 60
    time = floor(time / 60)
    date.minute = time % 60
    date.hour = hour + floor(time / 60)
    else
    else
    date.second = 0
    date.second = 0
    Line 172: Line 170:
    end
    end
    if H then
    if H then
    -- It is not possible to set M or S without also setting H.
    date.hastime = true
    date.hastime = true
    else
    else
    Line 203: Line 202:
    -- Valid option settings are:
    -- Valid option settings are:
    -- am: 'am', 'a.m.', 'AM', 'A.M.'
    -- am: 'am', 'a.m.', 'AM', 'A.M.'
    --    'pm', 'p.m.', 'PM', 'P.M.' (each has same meaning as corresponding item above)
    -- era: 'BCMINUS', 'BCNEGATIVE', 'BC', 'B.C.', 'BCE', 'B.C.E.', 'AD', 'A.D.', 'CE', 'C.E.'
    -- era: 'BCMINUS', 'BCNEGATIVE', 'BC', 'B.C.', 'BCE', 'B.C.E.', 'AD', 'A.D.', 'CE', 'C.E.'
    -- Option am = 'am' does not mean the hour is AM; it means 'am' or 'pm' is used, depending on the hour.
    -- Option am = 'am' does not mean the hour is AM; it means 'am' or 'pm' is used, depending on the hour,
    -- Similarly, era = 'BC' means 'BC' is used if year < 0.
    --    and am = 'pm' has the same meaning.
    -- Similarly, era = 'BC' means 'BC' is used if year <= 0.
    -- BCMINUS displays a MINUS if year < 0 and the display format does not include %{era}.
    -- BCMINUS displays a MINUS if year < 0 and the display format does not include %{era}.
    -- BCNEGATIVE is similar but displays a hyphen.
    -- BCNEGATIVE is similar but displays a hyphen.
    Line 212: Line 213:
    result = options1
    result = options1
    elseif type(options1) == 'string' then
    elseif type(options1) == 'string' then
    -- Example: 'am:AM era:BC'
    -- Example: 'am:AM era:BC' or 'am=AM era=BC'.
    for item in options1:gmatch('%S+') do
    for item in options1:gmatch('%S+') do
    local lhs, rhs = item:match('^(%w+)[:=](.+)$')
    local lhs, rhs = item:match('^(%w+)[:=](.+)$')
    Line 227: Line 228:
    return result
    return result
    end
    end
    local ampm_options = {
    -- lhs = input text accepted as an am/pm option
    -- rhs = code used internally
    ['am']  = 'am',
    ['AM']  = 'AM',
    ['a.m.'] = 'a.m.',
    ['A.M.'] = 'A.M.',
    ['pm']  = 'am',  -- same as am
    ['PM']  = 'AM',
    ['p.m.'] = 'a.m.',
    ['P.M.'] = 'A.M.',
    }


    local era_text = {
    local era_text = {
    Line 245: Line 259:


    local function get_era_for_year(era, year)
    local function get_era_for_year(era, year)
    return (era_text[era or 'BC'] or {})[year > 0 and 2 or 1] or ''
    return (era_text[era] or era_text['BC'])[year > 0 and 2 or 1] or ''
    end
    end


    Line 298: Line 312:
    ['AM'] = { 'AM', 'PM' },
    ['AM'] = { 'AM', 'PM' },
    ['A.M.'] = { 'A.M.', 'P.M.' },
    ['A.M.'] = { 'A.M.', 'P.M.' },
    })[amopt] or { 'am', 'pm' }
    })[ampm_options[amopt]] or { 'am', 'pm' }
    return (spaces == '' and '' or '&nbsp;') .. (value < 12 and ap[1] or ap[2])
    return (spaces == '' and '' or '&nbsp;') .. (value < 12 and ap[1] or ap[2])
    end
    end
    Line 524: Line 538:
    end
    end
    local list = { text = _list_text }
    local list = { text = _list_text }
    local count = math.floor((last - first)/7) + 1
    local count = floor((last - first)/7) + 1
    for i = 1, count do
    for i = 1, count do
    list[i] = Date(date, {day = first})
    list[i] = Date(date, {day = first})
    Line 600: Line 614:
    return true
    return true
    end
    end
    local ampm_options = {
    ['am']  = 'am',
    ['AM']  = 'AM',
    ['a.m.'] = 'a.m.',
    ['A.M.'] = 'A.M.',
    ['pm']  = 'am',  -- same as am
    ['PM']  = 'AM',
    ['p.m.'] = 'a.m.',
    ['P.M.'] = 'A.M.',
    }
    local item_count = 0
    local item_count = 0
    local index_time
    local index_time
    Line 697: Line 701:
    local function do_days(n)
    local function do_days(n)
    local forcetime, jd
    local forcetime, jd
    if math.floor(n) == n then
    if floor(n) == n then
    jd = lhs.jd
    jd = lhs.jd
    else
    else
    Line 727: Line 731:
    m = 0
    m = 0
    elseif is_prefix(id, 'months') then
    elseif is_prefix(id, 'months') then
    y = math.floor(num / 12)
    y = floor(num / 12)
    m = num % 12
    m = num % 12
    elseif is_prefix(id, 'weeks') then
    elseif is_prefix(id, 'weeks') then
    Line 759: Line 763:
    return Date(lhs, y, m, d)
    return Date(lhs, y, m, d)
    end
    end
    end
    if is_diff(rhs) then
    local days = rhs.daystotal
    if (is_sub or false) ~= (rhs.isnegative or false) then
    days = -days
    end
    return lhs + days
    end
    end
    end
    end
    Line 789: Line 800:
    -- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,
    -- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,
    -- which is from jd 1721425.5 to 1721426.49999.
    -- which is from jd 1721425.5 to 1721426.49999.
    value = math.floor(self.jd - 1721424.5)
    value = floor(self.jd - 1721424.5)
    elseif key == 'juliandate' or key == 'jd' or key == 'jdz' then
    elseif key == 'juliandate' or key == 'jd' or key == 'jdz' then
    local jd, jdz = julian_date(self)
    local jd, jdz = julian_date(self)
    Line 798: Line 809:
    elseif key == 'jdnoon' then
    elseif key == 'jdnoon' then
    -- Julian date at noon (an integer) on the calendar day when jd occurs.
    -- Julian date at noon (an integer) on the calendar day when jd occurs.
    value = math.floor(self.jd + 0.5)
    value = floor(self.jd + 0.5)
    elseif key == 'isleapyear' then
    elseif key == 'isleapyear' then
    value = is_leap_year(self.year, self.calname)
    value = is_leap_year(self.year, self.calname)
    Line 941: Line 952:
    newdate.hastime = true
    newdate.hastime = true
    end
    end
    elseif num ~= math.floor(num) then
    elseif num ~= floor(num) then
    -- The given value was a number. The time will be used
    -- The given value was a number. The time will be used
    -- if the fractional part is nonzero.
    -- if the fractional part is nonzero.
    Line 1,009: Line 1,020:
    }
    }
    return setmetatable(readonly, mt)
    return setmetatable(readonly, mt)
    end
    local function _age_ym(diff)
    -- Return text specifying date difference in years, months.
    local sign = diff.isnegative and MINUS or ''
    local mtext = number_name(diff.months, 'month')
    local result
    if diff.years > 0 then
    local ytext = number_name(diff.years, 'year')
    if diff.months == 0 then
    result = ytext
    else
    result = ytext .. ',&nbsp;' .. mtext
    end
    else
    if diff.months == 0 then
    sign = ''
    end
    result = mtext
    end
    return sign .. result
    end
    end


    Line 1,042: Line 1,032:
    __index = function (self, key)
    __index = function (self, key)
    local value
    local value
    if key == 'age_ym' then
    if key == 'daystotal' then
    value = _age_ym(self)
    elseif key == 'daystotal' then
    value = self.date1.jdz - self.date2.jdz
    value = self.date1.jdz - self.date2.jdz
    end
    end
    Line 1,055: Line 1,043:


    function DateDiff(date1, date2)  -- for forward declaration above
    function DateDiff(date1, date2)  -- for forward declaration above
    -- Return a table with the difference between the two dates (date1 - date2).
    -- Return a table with the difference between two dates (date1 - date2).
    -- The difference is negative if date1 is older than date2.
    -- The difference is negative if date1 is older than date2.
    -- Return nothing if invalid.
    -- Return nothing if invalid.
    -- If d = date1 - date2 then
    --    date1 = date2 + d
    -- If date1 >= date2 and the dates have no H:M:S time specified then
    --    date1 = date2 + (d.years..'y') + (d.months..'m') + d.days
    -- where the larger time units are added first.
    -- The result of Date(2015,1,x) + '1m' is Date(2015,2,28) for
    -- x = 28, 29, 30, 31. That means, for example,
    --    d = Date(2015,3,3) - Date(2015,1,31)
    -- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1).
    if not (is_date(date1) and is_date(date2) and date1.calname == date2.calname) then
    if not (is_date(date1) and is_date(date2) and date1.calname == date2.calname) then
    return
    return
    Line 1,066: Line 1,063:
    date1, date2 = date2, date1
    date1, date2 = date2, date1
    end
    end
    -- It is known that date1 >= date2.
    -- It is known that date1 >= date2 (period is from date2 to date1).
    local y1, m1 = date1.year, date1.month
    local y1, m1 = date1.year, date1.month
    local y2, m2 = date2.year, date2.month
    local y2, m2 = date2.year, date2.month
    local years, months, days = y1 - y2, m1 - m2, date1.day - date2.day
    local years = y1 - y2
    if days < 0 then
    local months = m1 - m2
    days = days + days_in_month(y2, m2, date2.calname)
    local d1 = date1.day + hms(date1)
    local d2 = date2.day + hms(date2)
    local days, time
    if d1 >= d2 then
    days = d1 - d2
    else
    months = months - 1
    months = months - 1
    -- Get days in previous month (before the "to" date) given December has 31 days.
    local dpm = m1 > 1 and days_in_month(y1, m1 - 1, date1.calname) or 31
    if d2 >= dpm then
    days = d1
    else
    days = dpm - d2 + d1
    end
    end
    end
    if months < 0 then
    if months < 0 then
    years = years - 1
    months = months + 12
    months = months + 12
    years = years - 1
    end
    end
    days, time = math.modf(days)
    local H, M, S = h_m_s(time)
    return setmetatable({
    return setmetatable({
    date1 = date1,
    date1 = date1,
    Line 1,084: Line 1,095:
    months = months,
    months = months,
    days = days,
    days = days,
    hours = H,
    minutes = M,
    seconds = S,
    isnegative = isnegative,
    isnegative = isnegative,
    }, diffmt)
    }, diffmt)