-- Date parsing and formatting utilities local M = {} -- Date format patterns local DATE_PATTERNS = { ISO_8601 = "^%d%d%d%d%-%d%d%-%d%d$", ISO_8601_TIME = "^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%dZ?$", ISO_8601_OFFSET = "^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d[%+%-]%d%d:%d%d$", RELATIVE = "^(%d+)([hdwmy])$", NATURAL = "^(%d%d%d%d)%-(%d%d)%-(%d%d)$" } -- Month names local MONTH_NAMES = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" } local MONTH_SHORT = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" } -- Parse date string to timestamp function M.parse_date(date_string) if not date_string or date_string == "" then return nil end -- Handle relative dates if date_string:match(DATE_PATTERNS.RELATIVE) then return M.parse_relative_date(date_string) end -- Handle ISO 8601 with time if date_string:match(DATE_PATTERNS.ISO_8601_TIME) then return M.parse_iso8601_datetime(date_string) end -- Handle ISO 8601 with offset if date_string:match(DATE_PATTERNS.ISO_8601_OFFSET) then return M.parse_iso8601_with_offset(date_string) end -- Handle natural language dates if date_string:match(DATE_PATTERNS.NATURAL) then return M.parse_natural_date(date_string) end -- Handle ISO 8601 date only if date_string:match(DATE_PATTERNS.ISO_8601) then return M.parse_iso8601_date(date_string) end -- Handle common formats return M.parse_common_formats(date_string) end -- Parse ISO 8601 date function M.parse_iso8601_date(date_string) local year, month, day = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") if not year then return nil end local timestamp = os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = 0, min = 0, sec = 0 }) return timestamp end -- Parse ISO 8601 datetime function M.parse_iso8601_datetime(date_string) local year, month, day, hour, min, sec, timezone = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(Z?)$") if not year then return nil end local timestamp = os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = tonumber(hour), min = tonumber(min), sec = tonumber(sec) }) return timestamp end -- Parse ISO 8601 with timezone offset function M.parse_iso8601_with_offset(date_string) local year, month, day, hour, min, sec, offset_sign, offset_hour, offset_min = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)([%+%-])(%d%d):(%d%d)$") if not year then return nil end local timestamp = os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = tonumber(hour), min = tonumber(min), sec = tonumber(sec) }) -- Apply timezone offset (convert to UTC) local offset_total = tonumber(offset_hour) * 3600 + tonumber(offset_min) * 60 if offset_sign == "-" then offset_total = -offset_total end timestamp = timestamp - offset_total return timestamp end -- Parse natural date function M.parse_natural_date(date_string) local year, month, day = date_string:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") if not year then return nil end -- Handle natural language month names if not month then local month_name, day_part, year_part = date_string:match("^(%a+)%s+(%d+)%s+(%d+)$") if month_name and day_part and year_part then month = M.get_month_number(month_name) day = tonumber(day_part) year = tonumber(year_part) end end if year and month and day then return os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = 0, min = 0, sec = 0 }) end return nil end -- Parse relative date function M.parse_relative_date(date_string) local amount, unit = date_string:match("^(%d+)([hdwmy])$") if not amount or not unit then return nil end local current_time = os.time() amount = tonumber(amount) local seconds = 0 if unit == "s" then seconds = amount elseif unit == "m" then seconds = amount * 60 elseif unit == "h" then seconds = amount * 3600 elseif unit == "d" then seconds = amount * 86400 elseif unit == "w" then seconds = amount * 604800 elseif unit == "m" then seconds = amount * 2592000 -- 30 days elseif unit == "y" then seconds = amount * 31536000 -- 365 days end return current_time - seconds end -- Parse common formats function M.parse_common_formats(date_string) local formats = { "^%d%d%d%d%/%d%d%/%d%d$", -- MM/DD/YYYY "^%d%d%/%d%d%/%d%d%d%d$", -- M/D/YYYY "^%d%d%-%d%d%-%d%d%d%d$", -- MM-DD-YYYY } for _, pattern in ipairs(formats) do if date_string:match(pattern) then -- Try to parse with Lua's built-in date parsing local timestamp = os.time({ year = tonumber(date_string:sub(-4)), month = tonumber(date_string:sub(1, 2)), day = tonumber(date_string:sub(4, 5)), hour = 0, min = 0, sec = 0 }) if timestamp > 0 then return timestamp end end end return nil end -- Format timestamp to string function M.format_date(timestamp, format) format = format or "%Y-%m-%d" if not timestamp then return "" end return os.date(format, timestamp) end -- Get relative time string function M.get_relative_time(timestamp) local current_time = os.time() local diff = current_time - timestamp if diff < 60 then return "just now" elseif diff < 3600 then local minutes = math.floor(diff / 60) return string.format("%d minute%s ago", minutes, minutes > 1 and "s" or "") elseif diff < 86400 then local hours = math.floor(diff / 3600) return string.format("%d hour%s ago", hours, hours > 1 and "s" or "") elseif diff < 2592000 then local days = math.floor(diff / 86400) return string.format("%d day%s ago", days, days > 1 and "s" or "") elseif diff < 31536000 then local months = math.floor(diff / 2592000) return string.format("%d month%s ago", months, months > 1 and "s" or "") else local years = math.floor(diff / 31536000) return string.format("%d year%s ago", years, years > 1 and "s" or "") end end -- Get month number from name function M.get_month_number(month_name) local lower_name = month_name:lower() for i, name in ipairs(MONTH_NAMES) do if name:lower() == lower_name then return i end end for i, name in ipairs(MONTH_SHORT) do if name:lower() == lower_name then return i end end return nil end -- Get month name from number function M.get_month_name(month_number, short) if month_number < 1 or month_number > 12 then return nil end if short then return MONTH_SHORT[month_number] else return MONTH_NAMES[month_number] end end -- Validate date string function M.is_valid_date(date_string) return M.parse_date(date_string) ~= nil end -- Add time to timestamp function M.add_time(timestamp, amount, unit) unit = unit or "days" local seconds = 0 if unit == "seconds" then seconds = amount elseif unit == "minutes" then seconds = amount * 60 elseif unit == "hours" then seconds = amount * 3600 elseif unit == "days" then seconds = amount * 86400 elseif unit == "weeks" then seconds = amount * 604800 elseif unit == "months" then seconds = amount * 2592000 elseif unit == "years" then seconds = amount * 31536000 end return timestamp + seconds end -- Get date range function M.get_date_range(start_date, end_date) local start_timestamp = M.parse_date(start_date) local end_timestamp = M.parse_date(end_date) if not start_timestamp or not end_timestamp then return nil end return { start_timestamp = start_timestamp, end_timestamp = end_timestamp, start_formatted = M.format_date(start_timestamp), end_formatted = M.format_date(end_timestamp), duration_days = math.floor((end_timestamp - start_timestamp) / 86400) } end -- Get week start/end function M.get_week_bounds(timestamp) timestamp = timestamp or os.time() local date_table = os.date("*t", timestamp) local day_of_week = date_table.wday -- Sunday = 1, Monday = 2, etc. -- Adjust to Monday = 0 day_of_week = (day_of_week + 5) % 7 local week_start = timestamp - (day_of_week * 86400) local week_end = week_start + (6 * 86400) return { start_timestamp = week_start, end_timestamp = week_end, start_formatted = M.format_date(week_start), end_formatted = M.format_date(week_end) } end -- Get month start/end function M.get_month_bounds(timestamp) timestamp = timestamp or os.time() local date_table = os.date("*t", timestamp) local month_start = os.time({ year = date_table.year, month = date_table.month, day = 1, hour = 0, min = 0, sec = 0 }) local next_month = os.time({ year = date_table.year, month = date_table.month + 1, day = 1, hour = 0, min = 0, sec = 0 }) local month_end = next_month - 1 return { start_timestamp = month_start, end_timestamp = month_end, start_formatted = M.format_date(month_start), end_formatted = M.format_date(month_end) } end -- Get time zones function M.get_timezones() return { "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Europe/London", "Europe/Paris", "Asia/Tokyo", "Australia/Sydney" } end return M