Module:OnThisDay
Appearance
Documentation for this module may be created at Module:OnThisDay/doc
local p = {}
----------------------------------------------------------------
-- CONFIG: Week 1 anchor for King Warz Charts
-- Week 1 starts: 2020-12-25 (Friday)
----------------------------------------------------------------
local WEEK1_Y, WEEK1_M, WEEK1_D = 2020, 12, 25
----------------------------------------------------------------
-- Load data from Module:OnThisDay/Data
----------------------------------------------------------------
local ok, data = pcall(mw.loadData, 'Module:OnThisDay/Data')
if not ok or type(data) ~= 'table' then
data = {}
end
-- Extra day-precise events (certs, first plays, debuts, first #1s),
-- generated by the Control Center's otd_events.py
local ok2, extraEvents = pcall(mw.loadData, 'Module:OnThisDay/Extra')
if not ok2 or type(extraEvents) ~= 'table' then
extraEvents = {}
end
----------------------------------------------------------------
-- Date helpers (pure Lua date math; no os.time needed)
-- Uses "days from civil" algorithms (safe in Scribunto)
----------------------------------------------------------------
local function isLeap(y)
return (y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0)
end
local function daysInMonth(y, m)
local dim = {31,28,31,30,31,30,31,31,30,31,30,31}
if m == 2 and isLeap(y) then
return 29
end
return dim[m] or 30
end
-- Convert civil date -> serial day number (Rata Die-ish)
local function daysFromCivil(y, m, d)
-- Howard Hinnant algorithm
y = y - (m <= 2 and 1 or 0)
local era = math.floor(y / 400)
local yoe = y - era * 400
local doy = math.floor((153 * (m + (m > 2 and -3 or 9)) + 2) / 5) + d - 1
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
return era * 146097 + doe
end
-- Convert serial day number -> civil date
local function civilFromDays(z)
-- Inverse Howard Hinnant algorithm
local era = math.floor(z / 146097)
local doe = z - era * 146097
local yoe = math.floor((doe - math.floor(doe/1460) + math.floor(doe/36524) - math.floor(doe/146096)) / 365)
local y = yoe + era * 400
local doy = doe - (yoe * 365 + math.floor(yoe/4) - math.floor(yoe/100))
local mp = math.floor((5 * doy + 2) / 153)
local d = doy - math.floor((153 * mp + 2) / 5) + 1
local m = mp + (mp < 10 and 3 or -9)
y = y + (m <= 2 and 1 or 0)
return y, m, d
end
local function pad2(n)
if n < 10 then return "0" .. tostring(n) end
return tostring(n)
end
local function toISO(y, m, d)
return tostring(y) .. "-" .. pad2(m) .. "-" .. pad2(d)
end
local function parseISO(s)
if type(s) ~= 'string' then return nil end
local y, m, d = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
if not y then return nil end
y, m, d = tonumber(y), tonumber(m), tonumber(d)
if not y or not m or not d then return nil end
if m < 1 or m > 12 then return nil end
local dim = daysInMonth(y, m)
if d < 1 or d > dim then return nil end
return y, m, d
end
-- Week number -> Week start ISO date (Friday)
local function weekToISO(weekNum)
if not weekNum or weekNum < 1 then return nil end
local base = daysFromCivil(WEEK1_Y, WEEK1_M, WEEK1_D)
local z = base + (weekNum - 1) * 7
local y, m, d = civilFromDays(z)
return toISO(y, m, d)
end
----------------------------------------------------------------
-- Helpers
----------------------------------------------------------------
local u = mw.ustring
local TITLE_JOINERS = {
['of'] = true, ['the'] = true, ['and'] = true, ['a'] = true,
['an'] = true, ['in'] = true, ['on'] = true, ['to'] = true,
['for'] = true, ['with'] = true, ['from'] = true, ['by'] = true,
['at'] = true, ['or'] = true,
}
local function titlecase(str)
if not str or str == '' then
return ''
end
str = mw.text.trim(str)
str = u.lower(str)
local first = true
local function up_word(word)
local bare = u.match(word, '^%p*(.-)%p*$') or word
if not first and TITLE_JOINERS[bare] then
return word -- joiners stay lowercase mid-title
end
first = false
-- capitalize the first LETTER, skipping leading punctuation
-- so '(rock' becomes '(Rock', not '(rock'
local pre, ch, rest = u.match(word, '^(%A*)(%a)(.*)$')
if ch then
return pre .. u.upper(ch) .. rest
end
return word
end
str = u.gsub(str, '%S+', up_word)
return str
end
local function normalizeArtistList(raw)
if not raw or raw == '' then
return ''
end
local parts = {}
for part in mw.text.gsplit(raw, ",") do
part = mw.text.trim(part)
part = titlecase(part)
table.insert(parts, part)
end
return table.concat(parts, ", ")
end
local function normalizeNo1Field(text, isAlbum)
if not text or text == '' then
return text
end
local linkInner, afterLink = text:match("%[%[([^%]]+)%]%](.*)")
if not linkInner then
return titlecase(text)
end
local target, label = linkInner:match("^(.-)|(.+)$")
if not target then
target = linkInner
label = linkInner
end
label = titlecase(label)
if isAlbum then
label = "''" .. label .. "''"
end
local changed = false
local beforeSmall, insideParens, afterSmall =
afterLink:match("^(.-)<small>%s*%((.-)%)%s*</small>(.*)$")
if insideParens then
local artistsText = normalizeArtistList(insideParens)
afterLink = (beforeSmall or "") ..
"<small>(" .. artistsText .. ")</small>" ..
(afterSmall or "")
changed = true
else
local b2, insidePlain, a2 =
afterLink:match("^(.-)<small>%s*(.-)%s*</small>(.*)$")
if insidePlain then
local artistsText = normalizeArtistList(insidePlain)
afterLink = (b2 or "") ..
"<small>" .. artistsText .. "</small>" ..
(a2 or "")
changed = true
else
local b3, artistsPlain, a3 =
afterLink:match("^(%s*)%(%s*(.-)%s*%)(.*)$")
if artistsPlain then
local artistsText = normalizeArtistList(artistsPlain)
afterLink = (b3 or "") ..
"(" .. artistsText .. ")" ..
(a3 or "")
changed = true
end
end
end
if not changed then
local newAfter, n = afterLink:gsub("%(([^()]+)%)", function(inner)
local artistsText = normalizeArtistList(inner)
return "(" .. artistsText .. ")"
end, 1)
if n > 0 then
afterLink = newAfter
end
end
local link = "[[" .. target .. "|" .. label .. "]]"
return link .. afterLink
end
local function getMonthDay(args)
local month = tonumber(args.month)
local day = tonumber(args.day)
if not month or not day then
local lang = mw.getContentLanguage()
month = tonumber(lang:formatDate('m'))
day = tonumber(lang:formatDate('d'))
end
return month, day
end
local function targetDateString(year, month, day)
return string.format('%04d-%02d-%02d', year, month, day)
end
----------------------------------------------------------------
-- Normalize entry date:
-- - If entry.date is valid ISO, keep it
-- - Else if entry.week exists, compute ISO date from week anchor
-- - Else nil
----------------------------------------------------------------
local function getEntryISODate(entry)
local datestr = tostring(entry.date or '')
local y, m, d = parseISO(datestr)
if y then
return datestr
end
local w = tonumber(entry.week)
if w then
return weekToISO(w)
end
return nil
end
----------------------------------------------------------------
-- Build per-year index from the data module (using normalized dates)
----------------------------------------------------------------
local by_year = {}
local year_min, year_max = nil, nil
for md, entries in pairs(data) do
if type(entries) == 'table' then
for _, entry in ipairs(entries) do
local iso = getEntryISODate(entry)
if iso then
local year = tonumber(iso:sub(1,4))
if year then
if not by_year[year] then
by_year[year] = {}
end
-- store a lightweight copy with a normalized date
table.insert(by_year[year], {
date = iso,
week = entry.week,
no1song = entry.no1song,
no1album = entry.no1album
})
if not year_min or year < year_min then year_min = year end
if not year_max or year > year_max then year_max = year end
end
end
end
end
end
for year, list in pairs(by_year) do
table.sort(list, function(a, b)
return tostring(a.date or '') < tostring(b.date or '')
end)
end
----------------------------------------------------------------
-- For a given year and target month/day:
-- 1) pick the last entry whose date <= target
-- 2) if none, pick the first entry whose date >= target
----------------------------------------------------------------
local function pickNearestInYear(year, month, day)
local list = by_year[year]
if not list then
return nil
end
local target = targetDateString(year, month, day)
local chosenBefore = nil
local firstAfter = nil
for _, entry in ipairs(list) do
local d = tostring(entry.date or '')
if d ~= '' then
if d <= target then
chosenBefore = entry
elseif not firstAfter and d >= target then
firstAfter = entry
end
end
end
return chosenBefore or firstAfter
end
local function formatEntryParts(entry)
local datestr = tostring(entry.date or '')
local year = datestr:sub(1, 4)
local week = tonumber(entry.week)
local weekPart = ''
if week then
weekPart = string.format('[[Week %d]] – ', week)
end
local songLine, albumLine
if entry.no1song and entry.no1song ~= '' then
local songText = normalizeNo1Field(entry.no1song, false)
songLine = string.format(
'* %s – %s%s is the #1 song on the charts',
year,
weekPart,
songText
)
end
if entry.no1album and entry.no1album ~= '' then
local albumText = normalizeNo1Field(entry.no1album, true)
albumLine = string.format(
'* %s – %s%s is the #1 album on the charts',
year,
weekPart,
albumText
)
end
return songLine, albumLine
end
----------------------------------------------------------------
-- Main entry point
----------------------------------------------------------------
function p.main(frame)
-- prefer whichever frame actually carries month/day: template calls
-- put them in parent.args, direct {{#invoke:}} puts them in frame.args
local parent = frame:getParent()
local pargs = parent and parent.args or {}
local fargs = frame.args or {}
local args = pargs
if not (pargs.month or pargs.day) and (fargs.month or fargs.day) then
args = fargs
end
local month, day = getMonthDay(args)
if not month or not day then
return 'OnThisDay: could not determine date.'
end
local lang = mw.getContentLanguage()
local currentYear = tonumber(lang:formatDate('Y')) or 2025
-- Use actual min year found in data; fallback to 2020
local startYear = year_min or 2020
local picks = {}
for year = startYear, currentYear do
local entry = pickNearestInYear(year, month, day)
if entry then
table.insert(picks, entry)
end
end
local songLines, albumLines = {}, {}
for _, entry in ipairs(picks) do
local sLine, aLine = formatEntryParts(entry)
if sLine then table.insert(songLines, sLine) end
if aLine then table.insert(albumLines, aLine) end
end
local out = {}
table.insert(out, "'''On this day in King Warz Charts history:'''")
table.insert(out, '')
if #songLines > 0 then
table.insert(out, "''Songs''")
for _, line in ipairs(songLines) do
table.insert(out, line)
end
end
if #albumLines > 0 then
table.insert(out, '')
table.insert(out, "''Albums''")
for _, line in ipairs(albumLines) do
table.insert(out, line)
end
end
-- day-precise anniversaries from Module:OnThisDay/Extra
local mdKey = string.format('%02d-%02d', month, day)
local ex = extraEvents[mdKey]
if ex then
local function addSection(title, rows, cap, fmt)
if not rows then return end
local total = 0
for _ in ipairs(rows) do total = total + 1 end
if total == 0 then return end
table.insert(out, '')
table.insert(out, "''" .. title .. "''")
local n = 0
for _, r in ipairs(rows) do
n = n + 1
if cap and n > cap then
table.insert(out, string.format(
'* <small>...and %d more</small>', total - cap))
break
end
table.insert(out, fmt(r))
end
end
addSection('Artist birthdays', ex.birthdays, nil, function(r)
if r.dead then
return string.format('* %d – [[%s]] was born on this day',
r.year, r.artist)
end
return string.format(
'* %d – [[%s]] was born on this day (turns %d)',
r.year, r.artist, currentYear - r.year)
end)
addSection('Certifications', ex.certs, 6, function(r)
return string.format('* %d – [[%s]] reached %s',
r.year, r.page, r.label)
end)
addSection('First listens', ex.firstplays, 5, function(r)
return string.format('* %d – First ever play of [[%s]]',
r.year, r.page)
end)
addSection('Chart debuts', ex.debuts, 5, function(r)
local nm = r.album and ("''[[" .. r.page .. "]]''") or ("[[" .. r.page .. "]]")
local chart = r.album and 'album chart' or 'chart'
return string.format('* %d – %s debuted on the %s at #%d',
r.year, nm, chart, r.pos)
end)
addSection('First week at No. 1', ex.crownings, nil, function(r)
local nm = r.album and ("''[[" .. r.page .. "]]''") or ("[[" .. r.page .. "]]")
local chart = r.album and ' on the album chart' or ''
return string.format(
'* %d – %s hit #1 for the first time%s ([[Week %d]])',
r.year, nm, chart, r.week)
end)
addSection('Play milestones', ex.playmilestones, 5, function(r)
local nm = r.album and ("''[[" .. r.page .. "]]''") or ("[[" .. r.page .. "]]")
return string.format('* %d – %s logged its %s play',
r.year, nm, r.mlabel)
end)
addSection('Artist firsts', ex.artistfirsts, 4, function(r)
return string.format('* %d – First ever play of a [[%s]] song',
r.year, r.artist)
end)
addSection('Listening records', ex.recorddays, nil, function(r)
if r.rank == 1 then
return string.format(
'* %d – Your busiest listening day on record (%s plays)',
r.year, r.plays)
end
return string.format(
'* %d – Your %s-busiest listening day on record (%s plays)',
r.year, r.ranklabel, r.plays)
end)
addSection('Certification crown', ex.certcrowns, nil, function(r)
if r.prev and r.prev ~= '' then
return string.format(
'* %d – [[%s]] overtook [[%s]] as the most certified song in chart history',
r.year, r.page, r.prev)
end
return string.format(
'* %d – [[%s]] became the most certified song in chart history',
r.year, r.page)
end)
end
if #out <= 2 then
return string.format(
'No chart history recorded for this date yet (month=%d, day=%d).',
month, day)
end
return table.concat(out, '\n')
end
----------------------------------------------------------------
-- Debug helper
----------------------------------------------------------------
function p.debug(frame)
local parent = frame:getParent()
local args = parent and parent.args or frame.args or {}
local month, day = getMonthDay(args)
local out = {}
table.insert(out, string.format('Debug OnThisDay: month=%d, day=%d', month or -1, day or -1))
table.insert(out, string.format('\nData loaded ok: %s', tostring(ok)))
table.insert(out, '\nTop-level data type: ' .. type(data))
local years = {}
for y, _ in pairs(by_year) do
table.insert(years, y)
end
table.sort(years)
table.insert(out, '\n\nYears indexed in by_year: ' .. (#years > 0 and table.concat(years, ', ') or '(none)'))
table.insert(out, string.format('\nYear min/max: %s / %s', tostring(year_min), tostring(year_max)))
table.insert(out, string.format(
'\n\nWeek1 anchor used: %04d-%02d-%02d',
WEEK1_Y, WEEK1_M, WEEK1_D
))
local currentYear = tonumber(mw.getContentLanguage():formatDate('Y')) or 2025
local startYear = year_min or 2020
table.insert(out, '\n\nPicks per year for this month/day:')
for year = startYear, currentYear do
local e = pickNearestInYear(year, month, day)
if e then
table.insert(out, string.format(
'\n%d: week=%s, date=%s, song=%s, album=%s',
year,
tostring(e.week or ''),
tostring(e.date or ''),
tostring(e.no1song or ''),
tostring(e.no1album or '')
))
else
table.insert(out, string.format('\n%d: (no entry)', year))
end
end
return table.concat(out, '\n')
end
return p