Module:Date: Difference between revisions

    (rework date differences for more consistent years/months/days; differences include hours/minutes/seconds; can add 'date + diff'; tweaks)
    (date:list() now accepts a count; DateDiff methods age and duration; cleaner member names; remember if time entered with an am/pm option for default output)
    Line 85: Line 85:
    local a = floor((14 - date.month)/12)
    local a = floor((14 - date.month)/12)
    local y = date.year + 4800 - a
    local y = date.year + 4800 - a
    if date.calname == 'Julian' then
    if date.calendar == 'Julian' then
    offset = floor(y/4) - 32083
    offset = floor(y/4) - 32083
    else
    else
    Line 105: 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 calname = date.calname
    local calname = date.calendar
    local low, high  -- 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 == 'Gregorian' then
    if calname == 'Gregorian' then
    Line 166: Line 166:
    (-9999 <= y and y <= 9999 and
    (-9999 <= y and y <= 9999 and
    1 <= m and m <= 12 and
    1 <= m and m <= 12 and
    1 <= d and d <= days_in_month(y, m, date.calname)) then
    1 <= d and d <= days_in_month(y, m, date.calendar)) then
    return
    return
    end
    end
    Line 199: Line 199:
    -- If options1 is a string, return a table with its settings, or
    -- If options1 is a string, return a table with its settings, or
    -- if it is a table, use its settings.
    -- if it is a table, use its settings.
    -- Missing options are set from options2 or defaults.
    -- Missing options are set from table options2 or defaults.
    -- If a default is used, a flag is set so caller knows the value was not intentionally set.
    -- Valid option settings are:
    -- Valid option settings are:
    -- am: 'am', 'a.m.', 'AM', 'A.M.'
    -- am: 'am', 'a.m.', 'AM', 'A.M.'
    Line 209: Line 210:
    -- 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.
    local result = {}
    local result = { bydefault = {} }
    if type(options1) == 'table' then
    if type(options1) == 'table' then
    result = options1
    result.am = options1.am
    result.era = options1.era
    elseif type(options1) == 'string' then
    elseif type(options1) == 'string' then
    -- Example: 'am:AM era:BC' or 'am=AM era=BC'.
    -- Example: 'am:AM era:BC' or 'am=AM era=BC'.
    Line 224: Line 226:
    local defaults = { am = 'am', era = 'BC' }
    local defaults = { am = 'am', era = 'BC' }
    for k, v in pairs(defaults) do
    for k, v in pairs(defaults) do
    result[k] = result[k] or options2[k] or v
    if not result[k] then
    if options2[k] then
    result[k] = options2[k]
    else
    result[k] = v
    result.bydefault[k] = true
    end
    end
    end
    end
    return result
    return result
    Line 287: Line 296:
    M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
    M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
    S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
    S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
    j = { fmt = '%03d', fmt2 = '%d', field = 'doy' },
    j = { fmt = '%03d', fmt2 = '%d', field = 'dayofyear' },
    I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
    I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
    p = { field = 'hour', special = 'am' },
    p = { field = 'hour', special = 'am' },
    Line 372: Line 381:
    end
    end
    if type(fmt) ~= 'string' then
    if type(fmt) ~= 'string' then
    fmt = '%-d %B %-Y %{era}'
    fmt = 'dmy'
    if date.hastime then
    if date.hastime then
    if date.second > 0 then
    fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt
    fmt = '%H:%M:%S ' .. fmt
    else
    fmt = '%H:%M ' .. fmt
    end
    end
    end
    elseif fmt:find('%', 1, true) then
    return strftime(date, fmt, options)
    return strftime(date, fmt, options)
    end
    end
    if fmt:find('%', 1, true) then
    local function hm_fmt()
    return strftime(date, fmt, options)
    local plain = make_option_table(options, date.options).bydefault.am
    return plain and '%H:%M' or '%-I:%M %p'
    end
    end
    local need_time = date.hastime
    local t = collection()
    local t = collection()
    for item in fmt:gmatch('%S+') do
    for item in fmt:gmatch('%S+') do
    local f
    local f
    if item == 'hm' then
    if item == 'hm' then
    f = '%H:%M'
    f = hm_fmt()
    need_time = false
    elseif item == 'hms' then
    elseif item == 'hms' then
    f = '%H:%M:%S'
    f = '%H:%M:%S'
    need_time = false
    elseif item == 'ymd' then
    elseif item == 'ymd' then
    f = '%Y-%m-%d %{era}'
    f = '%Y-%m-%d %{era}'
    Line 403: Line 413:
    t:add(f)
    t:add(f)
    end
    end
    return strftime(date, t:join(' '), options)
    fmt = t:join(' ')
    if need_time then
    fmt = hm_fmt() .. ' ' .. fmt
    end
    return strftime(date, fmt, options)
    end
    end


    Line 480: Line 494:
    end
    end


    local function _make_list(date, spec)
    local function _date_list(date, spec)
    -- Return a possibly empty numbered table of dates meeting the specification.
    -- Return a possibly empty numbered table of dates meeting the specification.
    -- The spec should be a string like "Tue >=" meaning that the list will
    -- Dates in the list are in ascending order (oldest date first).
    -- hold dates for all Tuesdays on or after date, and in date's month.
    -- The spec should be a string of form "<count> <day> <op>"
    -- where each item is optional and
    --  count = number of items wanted in list
    --   day = abbreviation or name such as Mon or Monday
    --  op = >, >=, <, <= (default is > meaning after date)
    -- If no count is given, the list is for the specified days in date's month.
    -- The default day is date's day.
    -- The spec can also be a positive or negative number:
    --  -5 is equivalent to '5 <'
    --  5  is equivalent to '5' which is '5 >'
    local list = { text = _list_text }
    if not is_date(date) then
    if not is_date(date) then
    return 'Need a date (use "date:list()" with a colon).'
    return 'Need a date (use "date:list()" with a colon).'
    end
    end
    local want_dow, op
    local count, offset, operation
    local ops = {
    local ops = {
    ['>='] = { before = false, include = true  },
    ['>='] = { before = false, include = true  },
    Line 495: Line 519:
    }
    }
    if spec then
    if spec then
    if type(spec) ~= 'string' then
    if type(spec) == 'number' then
    return {}
    count = floor(spec + 0.5)
    end
    if count < 0 then
    for item in spec:gmatch('%S+') do
    count = -count
    if ops[item] then
    operation = ops['<']
    if op then
    end
    return {}
    elseif type(spec) == 'string' then
    end
    local num, day, op = spec:match('^%s*(%d*)%s*(%a*)%s*([<>=]*)%s*$')
    op = ops[item]
    if not num then
    else
    return list
    local dow = day_number(item)
    end
    if dow then
    if num ~= '' then
    if want_dow then
    count = tonumber(num)
    -- LATER Could handle more than one day, but probably not needed.
    end
    return {}
    if day ~= '' then
    end
    local dow = day_number(day:gsub('[sS]$', '')) -- accept plural days
    want_dow = dow
    if not dow then
    else
    return list
    return {}
    end
    end
    offset = dow - date.dow
    end
    end
    operation = ops[op]
    else
    return list
    end
    end
    end
    end
    local offset = want_dow and want_dow - date.dow or 0
    offset = offset or 0
    op = op or ops['>=']
    operation = operation or ops['>']
    local first, last
    local datefrom, dayfirst, daylast
    if op.before then
    if operation.before then
    if offset >= 0 and not (op.include and offset == 0) then
    if offset > 0 or (offset == 0 and not operation.include) then
    offset = offset - 7
    offset = offset - 7
    end
    end
    last = date.day + offset
    if count then
    first = last % 7
    if count > 1 then
    if first == 0 then
    offset = offset - 7*(count - 1)
    first = 7
    end
    datefrom = date + offset
    else
    daylast = date.day + offset
    dayfirst = daylast % 7
    if dayfirst == 0 then
    dayfirst = 7
    end
    end
    end
    else
    else
    if offset < 0 or (not op.include and offset == 0) then
    if offset < 0 or (offset == 0 and not operation.include) then
    offset = offset + 7
    offset = offset + 7
    end
    end
    first = date.day + offset
    if count then
    last = date.monthdays
    datefrom = date + offset
    else
    dayfirst = date.day + offset
    daylast = date.monthdays
    end
    end
    if not count then
    if daylast < dayfirst then
    return list
    end
    count = floor((daylast - dayfirst)/7) + 1
    datefrom = Date(date, {day = dayfirst})
    end
    end
    local list = { text = _list_text }
    local count = floor((last - first)/7) + 1
    for i = 1, count do
    for i = 1, count do
    list[i] = Date(date, {day = first})
    if not datefrom then break end  -- exceeds date limits
    first = first + 7
    list[i] = datefrom
    datefrom = datefrom + 7
    end
    end
    return list
    return list
    Line 760: Line 804:
    m = m + 12
    m = m + 12
    end
    end
    local d = math.min(lhs.day, days_in_month(y, m, lhs.calname))
    local d = math.min(lhs.day, days_in_month(y, m, lhs.calendar))
    return Date(lhs, y, m, d)
    return Date(lhs, y, m, d)
    end
    end
    end
    end
    if is_diff(rhs) then
    if is_diff(rhs) then
    local days = rhs.daystotal
    local days = rhs.age_days
    if (is_sub or false) ~= (rhs.isnegative or false) then
    if (is_sub or false) ~= (rhs.isnegative or false) then
    days = -days
    days = -days
    Line 789: Line 833:
    elseif key == 'dayofweekiso' then
    elseif key == 'dayofweekiso' then
    value = self.dowiso
    value = self.dowiso
    elseif key == 'doy' then
    elseif key == 'dayofyear' then
    local first = Date(self.year, 1, 1, self.calname).jdnoon
    local first = Date(self.year, 1, 1, self.calendar).jdnoon
    value = self.jdnoon - first + 1  -- day-of-year 1 to 366
    value = self.jdnoon - first + 1  -- day-of-year 1 to 366
    elseif key == 'dayofyear' then
    value = self.doy
    elseif key == 'era' then
    elseif key == 'era' then
    -- Era text (never a negative sign) from year and options.
    -- Era text (never a negative sign) from year and options.
    Line 811: Line 853:
    value = 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.calendar)
    elseif key == 'monthabbr' then
    elseif key == 'monthabbr' then
    value = month_info[self.month][1]
    value = month_info[self.month][1]
    elseif key == 'monthdays' then
    elseif key == 'monthdays' then
    value = days_in_month(self.year, self.month, self.calname)
    value = days_in_month(self.year, self.month, self.calendar)
    elseif key == 'monthname' then
    elseif key == 'monthname' then
    value = month_info[self.month][2]
    value = month_info[self.month][2]
    Line 885: Line 927:
    local newdate = {
    local newdate = {
    _id = uniq,
    _id = uniq,
    calname = 'Gregorian',  -- default is Gregorian calendar
    calendar = 'Gregorian',  -- default is Gregorian calendar
    hastime = false,  -- true if input sets a time
    hastime = false,  -- true if input sets a time
    hour = 0,  -- always set hour/minute/second so don't have to handle nil
    hour = 0,  -- always set hour/minute/second so don't have to handle nil
    minute = 0,
    minute = 0,
    second = 0,
    second = 0,
    options = make_option_table(),
    options = {},
    list = _make_list,
    list = _date_list,
    text = _date_text,
    text = _date_text,
    }
    }
    Line 904: Line 946:
    -- Ignore empty arguments after stripping so modules can directly pass template parameters.
    -- Ignore empty arguments after stripping so modules can directly pass template parameters.
    elseif calendars[vlower] then
    elseif calendars[vlower] then
    newdate.calname = calendars[vlower]
    newdate.calendar = calendars[vlower]
    elseif is_date(v) then
    elseif is_date(v) then
    -- Copy existing date (items can be overridden by other arguments).
    -- Copy existing date (items can be overridden by other arguments).
    Line 911: Line 953:
    end
    end
    is_copy = true
    is_copy = true
    newdate.calname = v.calname
    newdate.calendar = v.calendar
    newdate.hastime = v.hastime
    newdate.hastime = v.hastime
    newdate.options = v.options
    newdate.options = v.options
    Line 993: Line 1,035:
    newdate.hastime = true
    newdate.hastime = true
    end
    end
    newdate.calname = 'Gregorian'  -- ignore any given calendar name
    newdate.calendar = 'Gregorian'  -- ignore any given calendar name
    elseif argtype == 'setdate' then
    elseif argtype == 'setdate' then
    if tnums or not set_date_from_numbers(newdate, numbers) then
    if tnums or not set_date_from_numbers(newdate, numbers) then
    Line 1,020: Line 1,062:
    }
    }
    return setmetatable(readonly, mt)
    return setmetatable(readonly, mt)
    end
    local function _diff_age(diff, code, options)
    -- Return a tuple of values from diff as specified by code.
    -- If options == 'duration', an extra day is added.
    if not is_diff(diff) then
    local f = options == 'duration' and 'duration' or 'age'
    return 'Need a date difference (use "diff:' .. f .. '()" with a colon).'
    end
    local extra_day = options == 'duration' and 1 or 0
    if code == 'wd' or code == 'w' or code == 'd' then
    local d = diff.age_days + extra_day
    if code == 'd' then
    return d
    end
    local w = floor(d / 7)
    if code == 'w' then
    return w
    end
    return w, d % 7
    end
    local y, m, d = diff.years, diff.months, diff.days
    if extra_day > 0 then
    d = d + extra_day
    local to_date = diff.date1
    if d > days_in_month(to_date.year, to_date.month, to_date.calendar) then
    d = 1
    m = m + 1
    if m > 12 then
    m = 1
    y = y + 1
    end
    end
    end
    if code == 'ymd' then
    return y, m, d
    end
    if code == 'ym' then
    return y, m
    end
    if code == 'm' then
    return y * 12 + m
    end
    if code == 'ymwd' then
    return y, m, floor(d / 7), d % 7
    end
    return y  -- default: assume code == 'y'; ignore invalid codes
    end
    local function _diff_duration(diff, code)
    return _diff_age(diff, code, 'duration')
    end
    end


    Line 1,028: Line 1,121:
    end,
    end,
    __tostring = function (self)
    __tostring = function (self)
    return tostring(self.daystotal)
    return tostring(self.age_days)
    end,
    end,
    __index = function (self, key)
    __index = function (self, key)
    local value
    local value
    if key == 'daystotal' then
    if key == 'age_days' then
    value = self.date1.jdz - self.date2.jdz
    value = self.date1.jdz - self.date2.jdz
    end
    end
    Line 1,055: Line 1,148:
    --    d = Date(2015,3,3) - Date(2015,1,31)
    --    d = Date(2015,3,3) - Date(2015,1,31)
    -- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1).
    -- 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.calendar == date2.calendar) then
    return
    return
    end
    end
    Line 1,076: Line 1,169:
    months = months - 1
    months = months - 1
    -- Get days in previous month (before the "to" date) given December has 31 days.
    -- 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
    local dpm = m1 > 1 and days_in_month(y1, m1 - 1, date1.calendar) or 31
    if d2 >= dpm then
    if d2 >= dpm then
    days = d1
    days = d1
    Line 1,099: Line 1,192:
    seconds = S,
    seconds = S,
    isnegative = isnegative,
    isnegative = isnegative,
    age = _diff_age,
    duration = _diff_duration,
    }, diffmt)
    }, diffmt)
    end
    end