Module:Date: Difference between revisions
rework date differences for more consistent years/months/days; differences include hours/minutes/seconds; can add 'date + diff'; tweaks
(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 | -- 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 | 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 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 | 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 calname = date.calname | local calname = date.calname | ||
local | local low, high -- min/max limits for date ranges −9999-01-01 to 9999-12-31 | ||
if calname == ' | if calname == 'Gregorian' then | ||
low, high = -1930999.5, 5373484.49999 | |||
elseif calname == ' | elseif calname == 'Julian' then | ||
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 | 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 | ||
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 | ||
else | else | ||
time = time + 0.5 | |||
end | end | ||
date.hour, date.minute, date.second = h_m_s(time) | |||
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'] | 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 ' ') .. (value < 12 and ap[1] or ap[2]) | return (spaces == '' and '' or ' ') .. (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 = | 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 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 | 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 = | 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 = | 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 = | 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 ~= | 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 | end | ||
Line 1,042: | Line 1,032: | ||
__index = function (self, key) | __index = function (self, key) | ||
local value | local value | ||
if | if 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 | -- 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 | local years = y1 - y2 | ||
if | local months = m1 - m2 | ||
days = | 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 | ||
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) |