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

From Soyjak Wiki, the free ensoyclopedia
Jump to navigationJump to search

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):

  1. The earliest embargo date among issues that are recorded yet not released.
  2. deadline in 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 ISO YYYY-MM-DD format or it will be ignored as a "Malformed deadline".
  3. 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 at Soyjak 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 day(s) or fewer before the deadline (1) 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.max and schedule.warn: the window and the warning, in days
  • deadline: optional "advanced" override for breaks, agreed delays, etc
  • icons.<icon>: box icons
  • schedule..., 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