Module:Timeline: Difference between revisions

From aquila
Jump to navigation Jump to search
mNo edit summary
Tag: Reverted
mNo edit summary
 
(6 intermediate revisions by the same user not shown)
Line 1: Line 1:
local Calendar = require("Module:Calendar")
local p = {}
local p = {}
 
local dayLabels = {
local SEPARATOR = ";"
    "Early January", "January--February", "February--March", "March--April",  
local SPLIT_CHARACTERS = true
    "April--May", "May--June", "June", "June--July", "July--August",  
local LEGACY_MAX_EVENTS = 9
     "August--September", "September--October", "October--November",  
local EMPTY_ROW_TEXT = "No news"
     "November--December", "Late December"
local CARGO_TABLE = "NewsPosts"
local CARGO_FIELDS = "Page_Name,Title,Author,Publisher,RealDate,DayNum"
local CARGO_LIMIT = 1000
local DAY_LABELS = {}
for i, period in ipairs(Calendar.periods) do
    DAY_LABELS[i] = Calendar.pretty(period)
end
local DAYS_PER_YEAR = Calendar.daysPerYear
local SECTIONS = {
     Global    = "Global News",
    Domestic  = "Domestic News",
     Character = "Character News",
}
}
 
function p.render(frame)
local TYPE_ALIASES = {
     local year = tonumber(frame.args["Year"])
    ["global"]        = "Global News",
     local category = frame.args["Category"]
    ["global news"]    = "Global News",
     local categoryUpper = string.upper(category)
    ["domestic"]      = "Domestic News",
      
    ["domestic news"]  = "Domestic News",
     -- Query Cargo using preprocess to avoid argument table errors
    ["character"]      = "Character News",
     local query = string.format(
    ["character news"] = "Character News",
         '<div>{{#cargo_query:tables=NewsPosts|fields=Page_Name,Title,Author,Publisher,DayNum|where=YearNum=%d AND Type="%s News"|order by=RealDate ASC}}</div>',
}
         year, categoryUpper
     )
local COLUMNS = {
     local cargoHTML = frame:preprocess(query)
    { "In-universe date",    "24%" },
   
    { "Post",                "34%" },
    { "Character / nation",  "24%" },
    { "Real-world date",    "18%" },
}
local SEP_PATTERN = (SEPARATOR:gsub("%p", "%%%0"))
local function trim(s)
    return (tostring(s or ""):match("^%s*(.-)%s*$"))
end
local function errorText(message)
     return '<strong class="error">Timeline error: ' .. message .. "</strong>"
end
local function linkify(text)
    text = trim(text)
    if text == "" then return "" end
    if text:find("[[", 1, true) or text:find("^[%[<']") then
        return text
    end
    return "[[" .. text .. "]]"
end
local function linkifyCharacters(text)
    text = trim(text)
    if text == "" then return "" end
    if not SPLIT_CHARACTERS or text:find("[[", 1, true) or text:find("^[<']") then
        return linkify(text)
    end
     local parts = {}
    for part in (text .. ","):gmatch("(.-),") do
        part = trim(part)
        if part ~= "" then parts[#parts + 1] = linkify(part) end
    end
     return table.concat(parts, ", ")
end
local function normalizeTitle(name)
    name = trim((tostring(name or ""):gsub("_", " ")))
    return name:sub(1, 1):upper() .. name:sub(2)
end
local function linkTarget(wikitext)
    local target = tostring(wikitext or ""):match("^%[%[%s*:?([^%]|]+)")
    if target then
        return normalizeTitle(target)
     end
end
local function splitFields(line)
     line = line:gsub("(&#?%w+);", "%1\1")
     local fields = {}
    for field in (line .. SEPARATOR):gmatch("(.-)" .. SEP_PATTERN) do
         fields[#fields + 1] = trim((field:gsub("\1", ";")))
    end
    return fields
end
local function parseLine(line)
    line = trim(line)
    if line == "" then return nil end
    local f = splitFields(line)
    local post, char, date = f[1] or "", f[2] or "", f[3] or ""
    if post == "" and char == "" and date == "" then return nil end
    if post == "" then
        post = '<span class="error">(missing post title)</span>'
    else
         post = linkify(post)
    end
     if #f > 3 then
        post = post .. ' <span class="error">(too many “' .. SEPARATOR .. '” on this line)</span>'
     end
    return { post = post, char = linkifyCharacters(char), date = date }
end
local function collectManualEvents(getArg, day)
     local events = {}
     local events = {}
     for i = 1, 14 do events[i] = {} end
      
     local text = getArg("Day" .. day)
    -- Extract rows from the raw HTML Cargo outputs
     if text and trim(text) ~= "" then
    for row in string.gmatch(cargoHTML, "<tr>(.-)</tr>") do
        for line in (text .. "\n"):gmatch("(.-)\r?\n") do
         -- Matches the 5 columns we requested
            local ev = parseLine(line)
         local page, title, author, publisher, day = string.match(row, "<td>(.-)</td>%s*<td>(.-)</td>%s*<td>(.-)</td>%s*<td>(.-)</td>%s*<td>(.-)</td>")
            if ev then events[#events + 1] = ev end
         if day then
         end
            local d = tonumber(day)
    end
            if d and d >= 1 and d <= 14 then
                -- If Author is empty, use Publisher
    for n = 1, LEGACY_MAX_EVENTS do
                local charName = (author ~= "" and author) or publisher
         local prefix = "Day" .. day .. "_" .. n .. "_"
                table.insert(events[d], {
         local post = trim(getArg(prefix .. "Post"))
                    Post = title,
        if post ~= "" then
                    Page = page,
            events[#events + 1] = {
                    Char = charName,
                post = linkify(post),
                    Date = "Auto" -- Handled by Lua calculation below
                char = linkifyCharacters(getArg(prefix .. "Char")),
                })
                date = trim(getArg(prefix .. "Date")),
             end
             }
         end
         end
     end
     end
   
     -- Add manual events from Day1...Day14 parameters (for events without wiki pages)
     return events
     for i = 1, 14 do
end
         local manualData = frame.args["Day" .. i]
         if manualData and manualData ~= "" then
local function cargoEvents(typeName, year)
            for line in string.gmatch(manualData, "[^\r\n]+") do
    local byDay, names = {}, {}
                local post, char = string.match(line, "^%s*(.-)%s*;%s*(.-)%s*$")
     for day = 1, DAYS_PER_YEAR do byDay[day] = {} end
                if post and post ~= "" then
                    table.insert(events[i], { Post = post, Char = char or "" })
    if not (mw.ext and mw.ext.cargo) then
                 elseif post == nil and line ~= "" then
         return byDay, names, "the Cargo extension is not available to Lua"
                    table.insert(events[i], { Post = line, Char = "" })
    end
                 end
    local ok, rows = pcall(mw.ext.cargo.query, CARGO_TABLE, CARGO_FIELDS, {
        where = 'Type="' .. typeName .. '" AND YearNum=' .. string.format("%d", year),
         orderBy = "RealDate,_pageID",
        limit = CARGO_LIMIT,
    })
    if not ok then
        return byDay, names, tostring(rows)
    end
    for _, row in ipairs(rows) do
        local info = Calendar.info(row.RealDate)
        local day = tonumber(row.DayNum) or (info and info.day)
        local page = trim(row.Page_Name)
        if page ~= "" and day and day >= 1 and day <= DAYS_PER_YEAR then
            local title = trim(row.Title)
            if title == "" then
                title = page:match("[^/]+$") or page
            end
            local post
            if title == page then
                post = "[[" .. page .. "]]"
            else
                 post = "[[" .. page .. "|" .. title .. "]]"
            end
            local char
            if trim(row.Author) ~= "" then
                char = linkify(row.Author)
            else
                 char = linkifyCharacters(row.Publisher)
             end
             end
            names[normalizeTitle(page)] = true
            local list = byDay[day]
            list[#list + 1] = {
                post = post,
                char = char,
                date = info and info.iso or trim(row.RealDate),
            }
         end
         end
     end
     end
 
     -- Build the beautiful HTML table
     return byDay, names
    local html = {}
end
   
    -- Determine accent color based on category
local function cell(content, attrs)
     local headerBg = "#3a506b" -- Default slate
     return "<td" .. (attrs or "") .. ">" .. content .. "</td>"
    if categoryUpper == "CHARACTER" then headerBg = "#6e473b" end
end
   
    table.insert(html, '<div class="aquila-timeline-custom" style="border: 1px solid #d1d5db; border-radius: 12px; overflow: hidden; margin-bottom: 2em; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">')
local function rangeLabel(first, last, year)
     table.insert(html, '<table style="width: 100%; border-collapse: collapse; background: #ffffff; margin: 0;">')
     if first == last then
      
        return DAY_LABELS[first] .. ", Year " .. year
     -- Table Header
     end
    table.insert(html, '<tr>')
     return DAY_LABELS[first] .. " to " .. DAY_LABELS[last] .. ", Year " .. year
    table.insert(html, '<th colspan="4" style="background: ' .. headerBg .. '; color: #ffffff; font-family: \'Georgia\', serif; font-size: 1.1em; letter-spacing: 0.1em; text-align: center; padding: 12px; border-bottom: 3px solid #e0a96d;">' .. categoryUpper .. ' NEWS</th>')
end
    table.insert(html, '</tr>')
local function renderSection(title, year, schedule)
    local out = {}
      
      
    -- Table Wrapper
    out[#out + 1] = '<div style="overflow-x:auto; border: 1px solid #d1d5db; border-radius: 12px; margin-bottom: 2em; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">'
    out[#out + 1] = '<table class="aquila-timeline-custom">'
    -- Section Title
    out[#out + 1] = '<tr><th colspan="4" class="tl-header">' .. title .. "</th></tr>"
     -- Column Headers
     -- Column Headers
     table.insert(html, '<tr>')
     local head = {}
    table.insert(html, '<th style="background: #f8fafc; color: #1e293b; font-family: \'Georgia\', serif; padding: 10px; text-align: left; border-bottom: 2px solid #e2e8f0; width: 20%;">In-universe date</th>')
    for _, col in ipairs(COLUMNS) do
     table.insert(html, '<th style="background: #f8fafc; color: #1e293b; font-family: \'Georgia\', serif; padding: 10px; text-align: left; border-bottom: 2px solid #e2e8f0; width: 35%;">Post</th>')
        head[#head + 1] = '<th style="width:' .. col[2] .. ';">' .. col[1] .. "</th>"
     table.insert(html, '<th style="background: #f8fafc; color: #1e293b; font-family: \'Georgia\', serif; padding: 10px; text-align: left; border-bottom: 2px solid #e2e8f0; width: 25%;">Character / nation</th>')
    end
    table.insert(html, '<th style="background: #f8fafc; color: #1e293b; font-family: \'Georgia\', serif; padding: 10px; text-align: left; border-bottom: 2px solid #e2e8f0; width: 20%;">Real-world date</th>')
     out[#out + 1] = "<tr>" .. table.concat(head) .. "</tr>"
    table.insert(html, '</tr>')
 
    local day = 1
    -- Loop through days
     while day <= DAYS_PER_YEAR do
    local skipRows = 0
        local events = schedule[day]
    for i = 1, 14 do
        local count = #events
        if skipRows > 0 then
             skipRows = skipRows - 1
        if count > 0 then
            local default = Calendar.realDate(year, day)
            local dates, span = {}, {}
            for i, ev in ipairs(events) do
                dates[i] = (ev.date ~= "") and ev.date or default
            end
            local i = 1
            while i <= count do
                local j = i
                while j < count and dates[j + 1] == dates[i] do j = j + 1 end
                span[i] = j - i + 1
                i = j + 1
            end
            for idx, ev in ipairs(events) do
                local row = {}
                if idx == 1 then
                    row[#row + 1] = '<td class="tl-day"' .. (count > 1 and ' rowspan="' .. count .. '"' or "") .. '>' .. DAY_LABELS[day] .. ", Year " .. year .. '</td>'
                end
                row[#row + 1] = cell(ev.post)
                row[#row + 1] = cell(ev.char)
                if span[idx] then
                    row[#row + 1] = '<td class="tl-date"' .. (span[idx] > 1 and ' rowspan="' .. span[idx] .. '"' or "") .. '>' .. dates[idx] .. '</td>'
                end
                out[#out + 1] = "<tr>" .. table.concat(row) .. "</tr>"
            end
             day = day + 1
         else
         else
             local eventCount = #events[i]
             local first = day
            while day <= DAYS_PER_YEAR and #schedule[day] == 0 do
                day = day + 1
            end
            local last = day - 1
            local startDate, endDate = Calendar.realDate(year, first), Calendar.realDate(year, last)
              
              
             if eventCount == 0 then
             -- THIS IS THE FIX: Added <br> before the "to"
                -- Empty Day Logic (merges consecutive empty days)
            local dateText = (first == last) and startDate or (startDate .. " to<br>" .. endDate)
                local emptySpan = 1
                for j = i + 1, 14 do
            out[#out + 1] = "<tr>"
                    if #events[j] == 0 then
                 .. '<td class="tl-empty">' .. rangeLabel(first, last, year) .. '</td>'
                        emptySpan = emptySpan + 1
                 .. '<td colspan="2" class="tl-empty-text">' .. EMPTY_ROW_TEXT .. '</td>'
                    else
                 .. '<td class="tl-date">' .. dateText .. '</td>'
                        break
                 .. "</tr>"
                    end
        end
                end
    end
               
                local dateLabel = dayLabels[i]
    out[#out + 1] = "</table>"
                if emptySpan > 1 then
    out[#out + 1] = "</div>"
                    dateLabel = dayLabels[i] .. " to " .. dayLabels[i + emptySpan - 1]
    return table.concat(out, "\n")
                 end
end
                dateLabel = dateLabel .. ", Year " .. year
               
function p.newsType(frame)
                table.insert(html, '<tr style="background: #fcfcfd;">')
    local key = trim(frame.args[1]):lower()
                 table.insert(html, '<td style="padding: 12px 10px; border-bottom: 1px solid #e2e8f0; color: #64748b; font-style: italic; font-size: 0.9em;">' .. dateLabel .. '</td>')
    return TYPE_ALIASES[key] or SECTIONS.Character
                 table.insert(html, '<td colspan="3" style="padding: 12px 10px; border-bottom: 1px solid #e2e8f0; color: #94a3b8; font-style: italic;">No news</td>')
end
                 table.insert(html, '</tr>')
               
function p.render(frame)
                skipRows = emptySpan - 1
    local own = frame.args
            else
    local parent = frame:getParent()
                -- Event Logic
    local inherited = parent and parent.args or {}
                local rowspanAttr = ''
                if eventCount > 1 then
    local function getArg(name)
                    rowspanAttr = ' rowspan="' .. eventCount .. '" '
        local v = inherited[name]
                end
        if v == nil or trim(v) == "" then v = own[name] end
               
        return v
                for idx, event in ipairs(events[i]) do
    end
                    table.insert(html, '<tr style="background: #ffffff;">')
                   
    local key = trim(getArg("Category"))
                    -- Date Column (Only on first row of the day)
    local title = SECTIONS[key]
                    if idx == 1 then
    if not title then
                        table.insert(html, '<td' .. rowspanAttr .. 'style="padding: 12px 10px; border-bottom: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0; font-weight: bold; color: #1e293b; vertical-align: top;">' .. dayLabels[i] .. ', Year ' .. year .. '</td>')
        return errorText('unknown Category "' .. key .. '" (use Global, Domestic or Character).')
                    end
    end
                   
                    -- Post Column
    local year = tonumber(trim(getArg("Year")))
                    local postText = event.Post
    if not year or year < 1 or year ~= math.floor(year) then
                    if event.Page and event.Page ~= "" then
        return errorText("set <code>Year</code> to a whole number, 1 or higher.")
                        postText = '[[' .. event.Page .. '|' .. event.Post .. ']]'
    end
                    elseif not string.find(postText, "%[%[") and not string.find(postText, "<span>") then
                        postText = '[[' .. postText .. ']]'
    local auto, names, cargoError = {}, {}, nil
                    end
    if trim(getArg("Auto")):lower() ~= "no" then
                    table.insert(html, '<td style="padding: 12px 10px; border-bottom: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0; vertical-align: top;">' .. postText .. '</td>')
        auto, names, cargoError = cargoEvents(title, year)
                   
    end
                    -- Character Column
                    local charText = event.Char or ""
    local schedule = {}
                    if charText ~= "" and not string.find(charText, "%[%[") and not string.find(charText, "<span>") then
    for day = 1, DAYS_PER_YEAR do
                        charText = '[[' .. charText .. ']]'
        local events = {}
                    end
        for _, ev in ipairs(collectManualEvents(getArg, day)) do
                    table.insert(html, '<td style="padding: 12px 10px; border-bottom: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0; vertical-align: top;">' .. charText .. '</td>')
            local target = linkTarget(ev.post)
                   
            if not (target and names[target]) then
                    -- Real Date Column
                 events[#events + 1] = ev
                    local realDate = event.Date or ""
                    if realDate == "Auto" then
                        -- Year 1, Day 1 is 2026-07-28. Every year is 14 days.
                        local daysOffset = ((year - 1) * 14) + (i - 1)
                        local timestamp = os.time({year=2026, month=07, day=28, hour=12}) + (daysOffset * 86400)
                        realDate = os.date("%Y-%m-%d", timestamp)
                    end
                    table.insert(html, '<td style="padding: 12px 10px; border-bottom: 1px solid #e2e8f0; vertical-align: top; color: #64748b; font-size: 0.9em; white-space: nowrap;">' .. realDate .. '</td>')
                   
                    table.insert(html, '</tr>')
                 end
             end
             end
         end
         end
        for _, ev in ipairs(auto[day] or {}) do
            events[#events + 1] = ev
        end
        schedule[day] = events
    end
    local html = renderSection(title, year, schedule)
    if cargoError then
        html = html .. '\n<div class="error">Could not read the Cargo table “' .. CARGO_TABLE
            .. "”: " .. cargoError .. "</div>"
     end
     end
 
     return html
    table.insert(html, '</table>')
    table.insert(html, '</div>')
   
     return table.concat(html, "\n")
end
end
 
return p
return p

Latest revision as of 11:04, 24 October 2026

Documentation for this module may be created at Module:Timeline/doc

local Calendar = require("Module:Calendar")
 
local p = {}
 
local SEPARATOR = ";"
local SPLIT_CHARACTERS = true
local LEGACY_MAX_EVENTS = 9
local EMPTY_ROW_TEXT = "No news"
 
local CARGO_TABLE = "NewsPosts"
local CARGO_FIELDS = "Page_Name,Title,Author,Publisher,RealDate,DayNum"
local CARGO_LIMIT = 1000
 
local DAY_LABELS = {}
for i, period in ipairs(Calendar.periods) do
    DAY_LABELS[i] = Calendar.pretty(period)
end
local DAYS_PER_YEAR = Calendar.daysPerYear
 
local SECTIONS = {
    Global    = "Global News",
    Domestic  = "Domestic News",
    Character = "Character News",
}
 
local TYPE_ALIASES = {
    ["global"]         = "Global News",
    ["global news"]    = "Global News",
    ["domestic"]       = "Domestic News",
    ["domestic news"]  = "Domestic News",
    ["character"]      = "Character News",
    ["character news"] = "Character News",
}
 
local COLUMNS = {
    { "In-universe date",    "24%" },
    { "Post",                "34%" },
    { "Character / nation",  "24%" },
    { "Real-world date",     "18%" },
}
 
local SEP_PATTERN = (SEPARATOR:gsub("%p", "%%%0"))
 
local function trim(s)
    return (tostring(s or ""):match("^%s*(.-)%s*$"))
end
 
local function errorText(message)
    return '<strong class="error">Timeline error: ' .. message .. "</strong>"
end
 
local function linkify(text)
    text = trim(text)
    if text == "" then return "" end
    if text:find("[[", 1, true) or text:find("^[%[<']") then
        return text
    end
    return "[[" .. text .. "]]"
end
 
local function linkifyCharacters(text)
    text = trim(text)
    if text == "" then return "" end
    if not SPLIT_CHARACTERS or text:find("[[", 1, true) or text:find("^[<']") then
        return linkify(text)
    end
    local parts = {}
    for part in (text .. ","):gmatch("(.-),") do
        part = trim(part)
        if part ~= "" then parts[#parts + 1] = linkify(part) end
    end
    return table.concat(parts, ", ")
end
 
local function normalizeTitle(name)
    name = trim((tostring(name or ""):gsub("_", " ")))
    return name:sub(1, 1):upper() .. name:sub(2)
end
 
local function linkTarget(wikitext)
    local target = tostring(wikitext or ""):match("^%[%[%s*:?([^%]|]+)")
    if target then
        return normalizeTitle(target)
    end
end
 
local function splitFields(line)
    line = line:gsub("(&#?%w+);", "%1\1")
    local fields = {}
    for field in (line .. SEPARATOR):gmatch("(.-)" .. SEP_PATTERN) do
        fields[#fields + 1] = trim((field:gsub("\1", ";")))
    end
    return fields
end
 
local function parseLine(line)
    line = trim(line)
    if line == "" then return nil end
 
    local f = splitFields(line)
    local post, char, date = f[1] or "", f[2] or "", f[3] or ""
    if post == "" and char == "" and date == "" then return nil end
 
    if post == "" then
        post = '<span class="error">(missing post title)</span>'
    else
        post = linkify(post)
    end
    if #f > 3 then
        post = post .. ' <span class="error">(too many “' .. SEPARATOR .. '” on this line)</span>'
    end
    return { post = post, char = linkifyCharacters(char), date = date }
end
 
local function collectManualEvents(getArg, day)
    local events = {}
 
    local text = getArg("Day" .. day)
    if text and trim(text) ~= "" then
        for line in (text .. "\n"):gmatch("(.-)\r?\n") do
            local ev = parseLine(line)
            if ev then events[#events + 1] = ev end
        end
    end
 
    for n = 1, LEGACY_MAX_EVENTS do
        local prefix = "Day" .. day .. "_" .. n .. "_"
        local post = trim(getArg(prefix .. "Post"))
        if post ~= "" then
            events[#events + 1] = {
                post = linkify(post),
                char = linkifyCharacters(getArg(prefix .. "Char")),
                date = trim(getArg(prefix .. "Date")),
            }
        end
    end
 
    return events
end
 
local function cargoEvents(typeName, year)
    local byDay, names = {}, {}
    for day = 1, DAYS_PER_YEAR do byDay[day] = {} end
 
    if not (mw.ext and mw.ext.cargo) then
        return byDay, names, "the Cargo extension is not available to Lua"
    end
 
    local ok, rows = pcall(mw.ext.cargo.query, CARGO_TABLE, CARGO_FIELDS, {
        where = 'Type="' .. typeName .. '" AND YearNum=' .. string.format("%d", year),
        orderBy = "RealDate,_pageID",
        limit = CARGO_LIMIT,
    })
    if not ok then
        return byDay, names, tostring(rows)
    end
 
    for _, row in ipairs(rows) do
        local info = Calendar.info(row.RealDate)
        local day = tonumber(row.DayNum) or (info and info.day)
        local page = trim(row.Page_Name)
 
        if page ~= "" and day and day >= 1 and day <= DAYS_PER_YEAR then
            local title = trim(row.Title)
            if title == "" then
                title = page:match("[^/]+$") or page
            end
 
            local post
            if title == page then
                post = "[[" .. page .. "]]"
            else
                post = "[[" .. page .. "|" .. title .. "]]"
            end
 
            local char
            if trim(row.Author) ~= "" then
                char = linkify(row.Author)
            else
                char = linkifyCharacters(row.Publisher)
            end
 
            names[normalizeTitle(page)] = true
            local list = byDay[day]
            list[#list + 1] = {
                post = post,
                char = char,
                date = info and info.iso or trim(row.RealDate),
            }
        end
    end
 
    return byDay, names
end
 
local function cell(content, attrs)
    return "<td" .. (attrs or "") .. ">" .. content .. "</td>"
end
 
local function rangeLabel(first, last, year)
    if first == last then
        return DAY_LABELS[first] .. ", Year " .. year
    end
    return DAY_LABELS[first] .. " to " .. DAY_LABELS[last] .. ", Year " .. year
end
 
local function renderSection(title, year, schedule)
    local out = {}
    
    -- Table Wrapper
    out[#out + 1] = '<div style="overflow-x:auto; border: 1px solid #d1d5db; border-radius: 12px; margin-bottom: 2em; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">'
    out[#out + 1] = '<table class="aquila-timeline-custom">'
 
    -- Section Title
    out[#out + 1] = '<tr><th colspan="4" class="tl-header">' .. title .. "</th></tr>"
 
    -- Column Headers
    local head = {}
    for _, col in ipairs(COLUMNS) do
        head[#head + 1] = '<th style="width:' .. col[2] .. ';">' .. col[1] .. "</th>"
    end
    out[#out + 1] = "<tr>" .. table.concat(head) .. "</tr>"
 
    local day = 1
    while day <= DAYS_PER_YEAR do
        local events = schedule[day]
        local count = #events
 
        if count > 0 then
            local default = Calendar.realDate(year, day)
            local dates, span = {}, {}
            for i, ev in ipairs(events) do
                dates[i] = (ev.date ~= "") and ev.date or default
            end
            local i = 1
            while i <= count do
                local j = i
                while j < count and dates[j + 1] == dates[i] do j = j + 1 end
                span[i] = j - i + 1
                i = j + 1
            end
 
            for idx, ev in ipairs(events) do
                local row = {}
                if idx == 1 then
                    row[#row + 1] = '<td class="tl-day"' .. (count > 1 and ' rowspan="' .. count .. '"' or "") .. '>' .. DAY_LABELS[day] .. ", Year " .. year .. '</td>'
                end
                row[#row + 1] = cell(ev.post)
                row[#row + 1] = cell(ev.char)
                if span[idx] then
                    row[#row + 1] = '<td class="tl-date"' .. (span[idx] > 1 and ' rowspan="' .. span[idx] .. '"' or "") .. '>' .. dates[idx] .. '</td>'
                end
                out[#out + 1] = "<tr>" .. table.concat(row) .. "</tr>"
            end
            day = day + 1
        else
            local first = day
            while day <= DAYS_PER_YEAR and #schedule[day] == 0 do
                day = day + 1
            end
            local last = day - 1
 
            local startDate, endDate = Calendar.realDate(year, first), Calendar.realDate(year, last)
            
            -- THIS IS THE FIX: Added <br> before the "to"
            local dateText = (first == last) and startDate or (startDate .. " to<br>" .. endDate)
 
            out[#out + 1] = "<tr>"
                .. '<td class="tl-empty">' .. rangeLabel(first, last, year) .. '</td>'
                .. '<td colspan="2" class="tl-empty-text">' .. EMPTY_ROW_TEXT .. '</td>'
                .. '<td class="tl-date">' .. dateText .. '</td>'
                .. "</tr>"
        end
    end
 
    out[#out + 1] = "</table>"
    out[#out + 1] = "</div>"
    return table.concat(out, "\n")
end
 
function p.newsType(frame)
    local key = trim(frame.args[1]):lower()
    return TYPE_ALIASES[key] or SECTIONS.Character
end
 
function p.render(frame)
    local own = frame.args
    local parent = frame:getParent()
    local inherited = parent and parent.args or {}
 
    local function getArg(name)
        local v = inherited[name]
        if v == nil or trim(v) == "" then v = own[name] end
        return v
    end
 
    local key = trim(getArg("Category"))
    local title = SECTIONS[key]
    if not title then
        return errorText('unknown Category "' .. key .. '" (use Global, Domestic or Character).')
    end
 
    local year = tonumber(trim(getArg("Year")))
    if not year or year < 1 or year ~= math.floor(year) then
        return errorText("set <code>Year</code> to a whole number, 1 or higher.")
    end
 
    local auto, names, cargoError = {}, {}, nil
    if trim(getArg("Auto")):lower() ~= "no" then
        auto, names, cargoError = cargoEvents(title, year)
    end
 
    local schedule = {}
    for day = 1, DAYS_PER_YEAR do
        local events = {}
        for _, ev in ipairs(collectManualEvents(getArg, day)) do
            local target = linkTarget(ev.post)
            if not (target and names[target]) then
                events[#events + 1] = ev
            end
        end
        for _, ev in ipairs(auto[day] or {}) do
            events[#events + 1] = ev
        end
        schedule[day] = events
    end
 
    local html = renderSection(title, year, schedule)
    if cargoError then
        html = html .. '\n<div class="error">Could not read the Cargo table “' .. CARGO_TABLE
            .. "”: " .. cargoError .. "</div>"
    end
    return html
end
 
return p