Introducing: Representatives. swedishwin.com, chud.win, and soyzellig.party positions are vacant.
The new Community missions system is now live! Where anyone can propose a task, discuss nominations, or join the current mission. Community missions give editors a place to turn requests from the Janny noticeboard into focused projects. No mission can be too small
Module:News/schedule
| This module is rated as ready for general use. It is considered stable and may be used wherever appropriate. Improvements should be developed and tested in a sandbox before changing the live module. |
| This module depends on the following other modules: |
This module draws the Soy Chronicle's schedule for the newsroom: a "Next issue" box with a countdown and two progress bars, and a three-month calendar. Everything is worked out when the page renders, from the issue records in Module:News/data and the schedule settings in Module:News/config.
Usage
Do not invoke this module, call it through {{News/schedule}} instead, which also leads the stylesheet for the newsroom.
{{News/schedule|box}}
{{News/schedule|calendar}}
Parts
| Part | Output |
|---|---|
box |
The "next issue" box. See The box. Takes the optional page parameter.
|
calendar |
Last month, this month, and the next month side by side, with the schedule marked on the calendar. See the calendar. |
next |
A line that reports when the next issue is due, such as "Next issue: due by November 22, 2026 (9 days left". |
progress |
The time bar on its own, for example labeled "Day 5 of 14". |
Any other value will show an unknown part error and put the page in Category:Newspaper pages with errors.
How the deadline is chosen
The schedule counts down from the newest released issue. The next issue may come outschedule.min (7) days after it and must be out by schedule.max (14) days after. The flexibility allows for release anytime between the 7-14 day range. The deadline is the first of these that applies (there should always only be one):
- The earliest embargo date among issues that are recorded yet not released.
deadlinein the config (advanced only). If set manually by an editor, it will provide an exception, such as skipping a week. Normally it is absent. The config must be in ISOYYYY-MM-DDformat or it will be ignored as a "Malformed deadline".- The last day of the window.
With no released issue and no embargo, there is no deadline, if so the box will just say "Whenever the first one is ready".
The date used for today is the local wiki time.
The box
From top to bottom as it renders with styling:
- A refresh link, which purges the page.
- The clock icon, the heading "Next issue" and today's date.
- The time bar. It fills from the newest issue to the deadline, carries the deadline line as its label, and shows a tick where the window opens. It will issue warnings accordingly if the deadline is soon or is overdue.
- The steps bar, showing how many of the five progress steps are ticked. It follows the first of these that exists: the next scheduled (embargoed) issue, the page given in
page(if necessary), or the draft atSoyjak Wiki:The Soy Chronicle/Draft. A missing page or a redirect gives no bar. It will be blue while steps remain and green once it is 5/5.
- A countdown, such as "There are 9 days until November 22, 2026". It is left out when the deadline has passed or there is none.
- A note: the warning (below), "No 3. is scheduled for release.", or what the bar counts from.
The box's state is one of:
| State | When | Shown |
|---|---|---|
| none | No released issue or embargo | "Whenever the first one is ready", no bars |
| waiting | Before the window opens | "Between ... and ... (2 to 9 days left)" |
| due | Inside the window | "Due by ... (5 days left)" |
| warn | schedule.warn |
Warning icon and note: give the issue an embargo if it is far from done |
| scheduled | An issue is embargoed | The embargo as deadline and "No. N is scheduled for release." |
| overdue | The deadline has passed with nothing scheduled | Red border and warning, no countdown, warning to embargo immediately |
The calendar
Each month is a grid starting on Monday, with neighboring months' days in gray to fill out the weeks. Marked days:
- Today: Gold,
- Latest issue: Green, the newest released issue's date
- Window: Tan, only when the deadline comes from the window
- Scheduled release: Purple, every embargo date
A legend under the months lists only the mark(s) that appear.
Settings
As defined in Module:News/config:
schedule.min,schedule.maxandschedule.warn: the window and the warning, in daysdeadline: optional "advanced" override for breaks, agreed delays, etc
icons.<icon>: box iconsschedule...,calendar...,box..., etc: all the text
Notes
Styles comes from Template:News/styles.css (for where it is shared) and Template:News/newsroom.css (only for the newsroom). Both are loaded by {{News/schedule}}. If you invoke directly, the module will not load styling.
local p = {}
local getArgs = require('Module:Arguments').getArgs
local News = require('Module:News')
local cfg = News.cfg
local function short(iso)
return News.formatDate(News.need('shortDateFormat'), iso)
end
local function left(state, days)
if days <= 0 then return News.text(state, 'dueToday') end
if days == 1 then return News.text(state, 'dayLeft') end
return News.text(state, 'daysLeft', { days = days })
end
local function nextLine(state, s)
if s.phase == 'none' then return News.text(state, 'scheduleNone') end
if s.phase == 'overdue' then return News.text(state, 'scheduleOverdue', { date = short(s.deadline) }) end
if s.source ~= 'window' then
return News.text(state, 'scheduleDeadline', { date = short(s.deadline), left = left(state, s.left) })
end
if s.phase == 'waiting' then
return News.text(state, 'scheduleWindow', {
open = short(s.open),
close = short(s.close),
left = News.text(state, 'daysRange', { from = News.daysBetween(s.today, s.open), to = s.left }),
})
end
return News.text(state, 'scheduleDue', { date = short(s.close), left = left(state, s.left) })
end
local function percent(part, whole)
if whole <= 0 then return 100 end
return math.max(0, math.min(100, math.floor(part / whole * 100 + 0.5)))
end
local function progress(state, s, line)
if not s.start or not s.deadline then return '' end
local total = News.daysBetween(s.start, s.deadline)
local day = News.daysBetween(s.start, s.today)
local bar = mw.html.create('div'):addClass('sn-news-schedule-bar')
if s.phase == 'overdue' then bar:addClass('sn-news-schedule-overdue') end
bar:tag('div'):addClass('sn-news-schedule-fill'):cssText('width:' .. percent(day, total) .. '%')
if s.source == 'window' then
bar:tag('div'):addClass('sn-news-schedule-mark'):cssText('left:' .. percent(s.min, total) .. '%')
end
bar:tag('div')
:addClass('sn-news-schedule-text')
:wikitext(line or News.text(state, 'scheduleBar', { day = math.max(0, day), total = total }))
return tostring(bar)
end
local function marks(s)
local m = {}
local function mark(iso, class)
if iso then
m[iso] = m[iso] or {}
table.insert(m[iso], class)
end
end
if s.source == 'window' then
for i = s.min, s.max do
mark(News.addDays(s.start, i), 'sn-news-schedule-window')
end
end
if s.latest then mark(s.start, 'sn-news-schedule-issue') end
for _, release in ipairs(s.releases) do
mark(release, 'sn-news-schedule-release')
end
mark(s.today, 'sn-news-schedule-today')
return m
end
local function month(year, mon, m, seen)
local first = os.time({ year = year, month = mon, day = 1, hour = 12 })
local length = os.date('*t', os.time({ year = year, month = mon + 1, day = 0, hour = 12 })).day
local offset = (os.date('*t', first).wday + 5) % 7
local lang = mw.language.getContentLanguage()
local tbl = mw.html.create('table'):addClass('sn-news-schedule-month')
tbl:tag('caption'):wikitext(lang:formatDate('F Y', string.format('%04d-%02d-01', year, mon)))
local head = tbl:tag('tr')
for i = 0, 6 do
local day = News.addDays('2024-01-01', i)
head:tag('th'):attr('scope', 'col'):attr('title', lang:formatDate('l', day)):wikitext(lang:formatDate('D', day))
end
local previous = os.date('*t', os.time({ year = year, month = mon, day = 0, hour = 12 })).day
local row = tbl:tag('tr')
for i = offset - 1, 0, -1 do
row:tag('td'):addClass('sn-news-schedule-other'):wikitext(string.format('%02d', previous - i))
end
for d = 1, length do
if (offset + d - 1) % 7 == 0 and d > 1 then row = tbl:tag('tr') end
local date = string.format('%04d-%02d-%02d', year, mon, d)
local cell = row:tag('td'):wikitext(string.format('%02d', d))
for _, class in ipairs(m[date] or {}) do
cell:addClass(class)
seen[class] = true
end
end
for d = 1, (7 - (offset + length) % 7) % 7 do
row:tag('td'):addClass('sn-news-schedule-other'):wikitext(string.format('%02d', d))
end
return tbl
end
local function calendar(state, s)
local y, mo = s.today:match('^(%d+)%-(%d+)')
y, mo = tonumber(y), tonumber(mo)
local m = marks(s)
local seen = {}
local wrap = mw.html.create('div'):addClass('sn-news-schedule-calendar')
for shift = -1, 1 do
local t = os.date('*t', os.time({ year = y, month = mo + shift, day = 1, hour = 12 })) ---@diagnostic disable-line: assign-type-mismatch
wrap:node(month(t.year, t.month, m, seen))
end
local legend = wrap:tag('div'):addClass('sn-news-schedule-legend')
for _, item in ipairs({
{ 'sn-news-schedule-today', 'calendarToday' },
{ 'sn-news-schedule-issue', 'calendarIssue' },
{ 'sn-news-schedule-window', 'calendarWindow' },
{ 'sn-news-schedule-release', 'calendarRelease' },
}) do
if seen[item[1]] then legend:tag('span'):addClass(item[1]):wikitext(News.text(state, item[2])) end
end
return tostring(wrap)
end
local function boxLine(state, s)
if s.phase == 'overdue' then return News.text(state, 'boxOverdue', { date = short(s.deadline) }) end
if s.source ~= 'window' then
return News.text(state, 'boxDeadline', { date = short(s.deadline), left = left(state, s.left) })
end
if s.phase == 'waiting' then
return News.text(state, 'boxWindow', {
open = short(s.open),
close = short(s.close),
left = News.text(state, 'daysRange', { from = News.daysBetween(s.today, s.open), to = s.left }),
})
end
return News.text(state, 'boxDue', { date = short(s.close), left = left(state, s.left) })
end
local function steps(state, s, page)
local name, label
if s.scheduled then
name, label = s.scheduled.page, News.numberText(state, s.scheduled)
else
local title = mw.title.new(page or News.need('pages.base') .. '/' .. News.need('pages.draft'))
if not title or not title.exists or title.isRedirect then return nil end
name, label = title.prefixedText, title.subpageText
end
local Status = require('Module:News/status')
local _, _, done, total = Status.checklist(state, Status.read(name))
local bar = mw.html.create('div'):addClass('sn-news-schedule-steps')
if done == total then bar:addClass('sn-news-schedule-complete') end
bar:tag('div'):addClass('sn-news-schedule-fill'):cssText('width:' .. percent(done, total) .. '%')
bar:tag('div'):addClass('sn-news-schedule-text'):wikitext(
News.text(state, 'stepsBar', { issue = label, done = done, total = total, percent = percent(done, total) })
)
return bar
end
local function countdown(state, s)
if s.phase == 'none' or s.phase == 'overdue' then return nil end
local date = short(s.deadline)
if s.left <= 0 then return News.text(state, 'countdownToday', { date = date }) end
if s.left == 1 then return News.text(state, 'countdownDay', { date = date }) end
return News.text(state, 'countdownDays', { days = s.left, date = date })
end
local function note(state, s)
if s.phase == 'overdue' then return News.text(state, 'warnOverdue', { date = short(s.deadline) }) end
if s.phase == 'warn' then return News.text(state, 'warnSoon', { date = short(s.deadline) }) end
if s.scheduled then return News.text(state, 'noteScheduled', { number = News.numberText(state, s.scheduled) }) end
if s.latest and s.start then
return News.text(state, 'noteCounting', { number = News.numberText(state, s.latest), date = short(s.start) })
end
return nil
end
local function icon(kind, size)
return '[[File:' .. News.need('icons.' .. kind) .. '|' .. size .. '|link=|alt=]]'
end
local function box(state, s, frame, page)
local wrap = mw.html.create('div'):addClass('sn-news-schedule plainlinks')
local refresh = '['
.. mw.title.getCurrentTitle():fullUrl({ action = 'purge' })
.. ' '
.. News.text(state, 'refresh')
.. ']'
wrap:tag('div')
:addClass('sn-news-schedule-refresh')
:wikitext(icon('refresh', '16px') .. ' ' .. refresh .. ' ' .. icon('refresh', '16px'))
local inner = wrap:tag('div'):addClass('sn-news-schedule-box'):addClass('sn-news-schedule-' .. s.phase)
inner:tag('div'):addClass('sn-news-schedule-clock'):wikitext(icon('clock', '48px'))
local main = inner:tag('div'):addClass('sn-news-schedule-main')
local head = main:tag('div'):addClass('sn-news-schedule-heading'):wikitext(News.text(state, 'boxHeading'))
head:tag('span'):addClass('sn-news-schedule-date'):wikitext(News.formatDate(News.need('dateFormat'), s.today))
local line = s.phase == 'none' and News.text(state, 'boxNone') or boxLine(state, s)
local bar = s.phase ~= 'none' and progress(state, s, line) or ''
if bar ~= '' then
main:wikitext(bar)
else
main:tag('div'):addClass('sn-news-schedule-line'):wikitext(line)
end
local stepsBar = steps(state, s, page)
if stepsBar then main:node(stepsBar) end
local count = countdown(state, s)
if count then main:tag('div'):addClass('sn-news-schedule-countdown'):wikitext(count) end
local text = note(state, s)
if text then
local warn = s.phase == 'warn' or s.phase == 'overdue'
main:tag('div'):addClass('sn-news-schedule-note'):wikitext((warn and icon('warn', '14px') .. ' ' or '') .. text)
end
return tostring(wrap)
end
local function part(state, what, s, frame, args)
if what == 'box' then return box(state, s, frame, args.page) end
if what == 'next' then return nextLine(state, s) end
if what == 'progress' then return progress(state, s) end
if what == 'calendar' then return calendar(state, s) end
return News.errorText(state, 'unknownPart', { value = tostring(what) })
end
function p.main(frame)
local args = getArgs(frame, { wrappers = cfg.wrappers })
local state = News.newState()
local s = News.schedule(News.today(frame))
local problem = s.badDeadline and News.errorText(state, 'malformedDeadline', { value = s.badDeadline }) or ''
return News.finish(state, problem .. part(state, args[1], s, frame, args))
end
return p
