diff --git a/lua/definitions/mw.lua b/lua/definitions/mw.lua index 112ff071d31..9acbdf610f6 100644 --- a/lua/definitions/mw.lua +++ b/lua/definitions/mw.lua @@ -446,6 +446,21 @@ function mw.language:formatDate(format, timestamp, localTime) return day or '' end return os.date(outFormat, ostimeWrapper(timestamp)) --[[@as string]] + elseif format == 'Y-m-d' then + if not timestamp then + return os.date('!%Y-%m-%d') --[[@as string]] + end + if type(timestamp) == 'string' and string.sub(timestamp, 1, 1) == '@' then + local seconds = tonumber(string.sub(timestamp, 2)) + if not seconds then return '' end + return os.date('!%Y-%m-%d', seconds) --[[@as string]] + end + if type(timestamp) == 'string' then + local year, month, day = parseDateString(timestamp) + if not year then return '' end + return year .. '-' .. month .. '-' .. day + end + return os.date('!%Y-%m-%d', ostimeWrapper(timestamp)) --[[@as string]] end return '' end diff --git a/lua/spec/snapshots/dota2 rankings.png b/lua/spec/snapshots/dota2 rankings.png index 9e547b50925..e61cb14992e 100644 Binary files a/lua/spec/snapshots/dota2 rankings.png and b/lua/spec/snapshots/dota2 rankings.png differ diff --git a/lua/spec/team_participants_dates_spec.lua b/lua/spec/team_participants_dates_spec.lua new file mode 100644 index 00000000000..72a5097262f --- /dev/null +++ b/lua/spec/team_participants_dates_spec.lua @@ -0,0 +1,335 @@ +--- Triple Comment to Enable our LLS Plugin +describe('TeamParticipants player dates', function() + local TeamParticipantsRepository + local Variables + local PageVariableNamespace + local LpdbQuery + + before_each(function() + Variables = require('Module:Variables') + Variables.varDefine('tournament_startdate', '2024-01-01') + Variables.varDefine('tournament_enddate', '2024-12-31') + PageVariableNamespace = require('Module:PageVariableNamespace') + TeamParticipantsRepository = require('Module:TeamParticipants/Repository') + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() return {} end) + end) + + after_each(function() + LpdbQuery:revert() + Variables.varDefine('tournament_startdate') + Variables.varDefine('tournament_enddate') + end) + + describe('getPlayersDates', function() + it('skips players with no pageName or TBD without querying', function() + local result = TeamParticipantsRepository.getPlayersDates( + { + {pageName = nil, extradata = {}}, + {pageName = 'TBD', extradata = {}}, + }, + {'Team Liquid'} + ) + assert.are_same({}, result) + assert.stub(LpdbQuery).called(0) + end) + + it('returns explicit dates without querying LPDB when both are set for all players', function() + local result = TeamParticipantsRepository.getPlayersDates( + { + {pageName = 'Alexis', extradata = {joinDate = '2024-03-01', leaveDate = '2024-09-01'}}, + }, + {'Team Liquid'} + ) + assert.are_equal('2024-03-01', result['Alexis'].joinDate) + assert.are_equal('2024-09-01', result['Alexis'].leaveDate) + assert.stub(LpdbQuery).called(0) + end) + + it('fetches joinDate from active transfer for active player', function() + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() + return {{date = '2024-03-15', player = 'Alexis'}} + end) + + local result = TeamParticipantsRepository.getPlayersDates( + {{pageName = 'Alexis', extradata = {}}}, + {'Team Liquid'} + ) + assert.are_equal('2024-03-15', result['Alexis'].joinDate) + assert.is_nil(result['Alexis'].leaveDate) + end) + + it('falls back to activeAlt when active query returns nothing', function() + local callCount = 0 + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() + callCount = callCount + 1 + if callCount == 2 then + return {{date = '2024-04-01', player = 'Alexis'}} + end + return {} + end) + + local result = TeamParticipantsRepository.getPlayersDates( + {{pageName = 'Alexis', extradata = {}}}, + {'Team Liquid'} + ) + assert.are_equal('2024-04-01', result['Alexis'].joinDate) + assert.are_equal(2, callCount) + end) + + it('fetches joinDate and leaveDate for former player', function() + local callCount = 0 + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() + callCount = callCount + 1 + if callCount == 1 then + return {{date = '2024-02-01', player = 'Alexis'}} -- active → joinDate + elseif callCount == 3 then + return {{date = '2024-08-15', player = 'Alexis'}} -- former → leaveDate + end + return {} + end) + + local result = TeamParticipantsRepository.getPlayersDates( + {{pageName = 'Alexis', extradata = {status = 'former'}}}, + {'Team Liquid'} + ) + assert.are_equal('2024-02-01', result['Alexis'].joinDate) + assert.are_equal('2024-08-15', result['Alexis'].leaveDate) + end) + + it('falls back to inactive query when former returns nothing for former player', function() + local callCount = 0 + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() + callCount = callCount + 1 + if callCount == 4 then + return {{date = '2024-09-30', player = 'Alexis'}} -- inactive → leaveDate + end + return {} + end) + + local result = TeamParticipantsRepository.getPlayersDates( + {{pageName = 'Alexis', extradata = {status = 'former'}}}, + {'Team Liquid'} + ) + assert.is_nil(result['Alexis'].joinDate) + assert.are_equal('2024-09-30', result['Alexis'].leaveDate) + assert.are_equal(4, callCount) -- active, activeAlt, former, inactive + end) + + it('explicit joinDate takes precedence over LPDB result', function() + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() + return {{date = '2024-03-15', player = 'Alexis'}} + end) + + local result = TeamParticipantsRepository.getPlayersDates( + {{pageName = 'Alexis', extradata = {joinDate = '2023-01-01'}}}, + {'Team Liquid'} + ) + assert.are_equal('2023-01-01', result['Alexis'].joinDate) + end) + + it('queries against the team-template columns, not display-name columns', function() + local capturedConditions + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function(_, options) + capturedConditions = options.conditions + return {} + end) + + TeamParticipantsRepository.getPlayersDates( + {{pageName = 'Alexis', extradata = {}}}, + {'team liquid'} + ) + + assert.is_truthy(capturedConditions:find('toteamtemplate', 1, true)) + assert.is_truthy(capturedConditions:find('fromteamtemplate', 1, true)) + assert.is_nil(capturedConditions:find('[[toteam::', 1, true)) + assert.is_nil(capturedConditions:find('[[fromteam::', 1, true)) + end) + + it('batches multiple players into a single query per status', function() + local callCount = 0 + local capturedConditions = {} + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function(_, options) + callCount = callCount + 1 + table.insert(capturedConditions, options.conditions) + if callCount == 1 then + return {{date = '2024-03-15', player = 'Alexis'}} + elseif callCount == 2 then + return {{date = '2024-04-20', player = 'Bob'}} + end + return {} + end) + + local result = TeamParticipantsRepository.getPlayersDates( + { + {pageName = 'Alexis', extradata = {}}, + {pageName = 'Bob', extradata = {}}, + }, + {'Team Liquid'} + ) + + assert.are_equal('2024-03-15', result['Alexis'].joinDate) + assert.are_equal('2024-04-20', result['Bob'].joinDate) + -- 2 queries total (active + activeAlt) for 2 players, not 4 (one per player per status) + assert.are_equal(2, callCount) + assert.is_truthy(capturedConditions[1]:find('Alexis', 1, true)) + assert.is_truthy(capturedConditions[1]:find('Bob', 1, true)) + -- Second query (activeAlt) should only ask about Bob since Alexis already resolved + assert.is_nil(capturedConditions[2]:find('"Alexis"', 1, true)) + assert.is_truthy(capturedConditions[2]:find('Bob', 1, true)) + end) + + it('picks the latest transfer per player when multiple rows are returned', function() + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() + return { + {date = '2024-05-01', player = 'Alexis'}, + {date = '2024-03-01', player = 'Alexis'}, + {date = '2024-04-01', player = 'Bob'}, + } + end) + + local result = TeamParticipantsRepository.getPlayersDates( + { + {pageName = 'Alexis', extradata = {}}, + {pageName = 'Bob', extradata = {}}, + }, + {'Team Liquid'} + ) + assert.are_equal('2024-05-01', result['Alexis'].joinDate) + assert.are_equal('2024-04-01', result['Bob'].joinDate) + end) + + it('matches transfers under either underscore or space variant of the page name', function() + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function() + return {{date = '2024-06-01', player = 'Some Player'}} + end) + + local result = TeamParticipantsRepository.getPlayersDates( + {{pageName = 'Some_Player', extradata = {}}}, + {'Team Liquid'} + ) + assert.are_equal('2024-06-01', result['Some_Player'].joinDate) + end) + + it('only queries former/inactive for players whose status is former', function() + local capturedConditions = {} + LpdbQuery:revert() + LpdbQuery = stub(mw.ext.LiquipediaDB, 'lpdb', function(_, options) + table.insert(capturedConditions, options.conditions) + return {} + end) + + TeamParticipantsRepository.getPlayersDates( + { + {pageName = 'Active', extradata = {}}, + {pageName = 'Former', extradata = {status = 'former'}}, + }, + {'Team Liquid'} + ) + + -- 4 calls total: active, activeAlt (both players), former, inactive (only former player) + assert.are_equal(4, #capturedConditions) + assert.is_truthy(capturedConditions[1]:find('Active', 1, true)) + assert.is_truthy(capturedConditions[1]:find('Former', 1, true)) + -- Latter two queries should only mention 'Former' + assert.is_nil(capturedConditions[3]:find('"Active"', 1, true)) + assert.is_truthy(capturedConditions[3]:find('Former', 1, true)) + end) + end) + + describe('setPageVars', function() + it('writes joindate and leavedate to global vars under team prefixes', function() + local TeamTemplateMock = require('wikis.commons.Mock.TeamTemplate') + TeamTemplateMock.setUp() + local globalVars = PageVariableNamespace() + + TeamParticipantsRepository.setPageVars({ + aliases = {'team liquid'}, + opponent = { + players = {{ + pageName = 'Alexis', + flag = 'us', + displayName = 'alexis', + faction = nil, + apiId = nil, + extradata = { + type = 'player', + joinDate = '2024-03-15', + leaveDate = nil, + }, + }}, + }, + }) + + assert.are_equal('2024-03-15', globalVars:get('Team Liquid_p1joindate')) + assert.is_nil(globalVars:get('Team Liquid_p1leavedate')) + TeamTemplateMock.tearDown() + end) + + it('writes both joindate and leavedate for former player', function() + local TeamTemplateMock = require('wikis.commons.Mock.TeamTemplate') + TeamTemplateMock.setUp() + local globalVars = PageVariableNamespace() + + TeamParticipantsRepository.setPageVars({ + aliases = {'team liquid'}, + opponent = { + players = {{ + pageName = 'Alexis', + flag = 'us', + displayName = 'alexis', + faction = nil, + apiId = nil, + extradata = { + type = 'player', + status = 'former', + joinDate = '2024-02-01', + leaveDate = '2024-08-15', + }, + }}, + }, + }) + + assert.are_equal('2024-02-01', globalVars:get('Team Liquid_p1joindate')) + assert.are_equal('2024-08-15', globalVars:get('Team Liquid_p1leavedate')) + TeamTemplateMock.tearDown() + end) + end) + + describe('parsePlayer date input', function() + it('stores explicit joindate from wiki input in extradata', function() + local TeamParticipantsWikiParser = require('Module:TeamParticipants/Parse/Wiki') + local player = TeamParticipantsWikiParser.parsePlayer{'Alexis', joindate = '2024-03-01'} + assert.are_equal('2024-03-01', player.extradata.joinDate) + assert.is_nil(player.extradata.leaveDate) + end) + + it('stores explicit leavedate from wiki input in extradata', function() + local TeamParticipantsWikiParser = require('Module:TeamParticipants/Parse/Wiki') + local player = TeamParticipantsWikiParser.parsePlayer{'Alexis', leavedate = '2024-09-01'} + assert.is_nil(player.extradata.joinDate) + assert.are_equal('2024-09-01', player.extradata.leaveDate) + end) + + it('stores nothing for missing date input', function() + local TeamParticipantsWikiParser = require('Module:TeamParticipants/Parse/Wiki') + local player = TeamParticipantsWikiParser.parsePlayer{'Alexis'} + assert.is_nil(player.extradata.joinDate) + assert.is_nil(player.extradata.leaveDate) + end) + + it('treats empty string date input as nil', function() + local TeamParticipantsWikiParser = require('Module:TeamParticipants/Parse/Wiki') + local player = TeamParticipantsWikiParser.parsePlayer{'Alexis', joindate = ''} + assert.is_nil(player.extradata.joinDate) + end) + end) +end) diff --git a/lua/wikis/commons/TeamParticipants/Controller.lua b/lua/wikis/commons/TeamParticipants/Controller.lua index ff03ef69e71..f5cf8451e9c 100644 --- a/lua/wikis/commons/TeamParticipants/Controller.lua +++ b/lua/wikis/commons/TeamParticipants/Controller.lua @@ -42,6 +42,7 @@ function TeamParticipantsController.fromTemplate(frame) local parsedData = TeamParticipantsWikiParser.parseWikiInput(parsedArgs) TeamParticipantsController.importParticipants(parsedData) TeamParticipantsController.fillIncompleteRosters(parsedData) + TeamParticipantsController.enrichPlayerDates(parsedData) local shouldStore = Logic.readBoolOrNil(args.store) ~= false and Lpdb.isStorageEnabled() @@ -142,6 +143,22 @@ function TeamParticipantsController.mergeManualAndImportedPlayers(manualPlayers, end) end +--- Enriches each player with join/leave dates from the transfer LPDB table. +--- Explicit dates from wiki input take precedence over auto-fetched ones. +---@param parsedData {participants: TeamParticipant[], expectedPlayerCount: integer?} +function TeamParticipantsController.enrichPlayerDates(parsedData) + Array.forEach(parsedData.participants, function(participant) + local players = participant.opponent.players or {} + local datesByPlayer = TeamParticipantsRepository.getPlayersDates(players, participant.aliases) + Array.forEach(players, function(player) + local dates = datesByPlayer[player.pageName] or {} + player.extradata = player.extradata or {} + player.extradata.joinDate = dates.joinDate + player.extradata.leaveDate = dates.leaveDate + end) + end) +end + --- Fills incomplete rosters for all participants with TBD players if needed. --- May mutate the input. ---@param parsedData {participants: TeamParticipant[], expectedPlayerCount: integer?} diff --git a/lua/wikis/commons/TeamParticipants/Parse/Wiki.lua b/lua/wikis/commons/TeamParticipants/Parse/Wiki.lua index 57ef8b57445..eba366a1236 100644 --- a/lua/wikis/commons/TeamParticipants/Parse/Wiki.lua +++ b/lua/wikis/commons/TeamParticipants/Parse/Wiki.lua @@ -237,6 +237,8 @@ function TeamParticipantsWikiParser.parsePlayer(playerInput) played = Logic.nilOr(playedInput, true), results = Logic.nilOr(resultsInput, playedInput, true), number = tonumber(playerInput.number), + joinDate = Logic.nilIfEmpty(playerInput.joindate), + leaveDate = Logic.nilIfEmpty(playerInput.leavedate), } return player end diff --git a/lua/wikis/commons/TeamParticipants/Repository.lua b/lua/wikis/commons/TeamParticipants/Repository.lua index b33121ec70f..766fdd2873d 100644 --- a/lua/wikis/commons/TeamParticipants/Repository.lua +++ b/lua/wikis/commons/TeamParticipants/Repository.lua @@ -8,13 +8,23 @@ local Lua = require('Module:Lua') local Array = Lua.import('Module:Array') +local DateExt = Lua.import('Module:Date/Ext') local FnUtil = Lua.import('Module:FnUtil') local Json = Lua.import('Module:Json') +local Logic = Lua.import('Module:Logic') local PageVariableNamespace = Lua.import('Module:PageVariableNamespace') local Table = Lua.import('Module:Table') local TeamTemplate = Lua.import('Module:TeamTemplate') local Variables = Lua.import('Module:Variables') +local Condition = Lua.import('Module:Condition') +local ConditionTree = Condition.Tree +local ConditionNode = Condition.Node +local ConditionUtil = Condition.Util +local Comparator = Condition.Comparator +local BooleanOperator = Condition.BooleanOperator +local ColumnName = Condition.ColumnName + local Opponent = Lua.import('Module:Opponent/Custom') local prizePoolVars = PageVariableNamespace('PrizePool') @@ -146,12 +156,125 @@ function TeamParticipantsRepository.setPageVars(participant) globalVars:set(combinedPrefix .. 'dn', player.displayName) globalVars:set(combinedPrefix .. 'id', player.apiId) globalVars:set(combinedPrefix .. 'faction', player.faction) - -- TODO: joindate, leavedate + globalVars:set(combinedPrefix .. 'joindate', player.extradata.joinDate) + globalVars:set(combinedPrefix .. 'leavedate', player.extradata.leaveDate) end) end) end) end +---@param playerPageNames string[] +---@param teamAliases string[] +---@param status 'active'|'activeAlt'|'inactive'|'former' +---@return table -- pageName -> date (YYYY-MM-DD) +function TeamParticipantsRepository.getPlayerTransferDates(playerPageNames, teamAliases, status) + if #playerPageNames == 0 then + return {} + end + + local startDate = Variables.varDefault('tournament_startdate', DateExt.getContextualDateOrNow()) + local endDate = Variables.varDefault('tournament_enddate', os.date('%F')) + + local toTeamFn, fromTeamFn + if status == 'active' then + toTeamFn, fromTeamFn = ConditionUtil.anyOf, ConditionUtil.noneOf + elseif status == 'activeAlt' or status == 'inactive' then + toTeamFn, fromTeamFn = ConditionUtil.anyOf, ConditionUtil.anyOf + else -- former + toTeamFn, fromTeamFn = ConditionUtil.noneOf, ConditionUtil.anyOf + end + + local variantToCanonical = {} + local nameVariants = {} + Array.forEach(playerPageNames, function(name) + variantToCanonical[name] = name + table.insert(nameVariants, name) + local alt = name:gsub('_', ' ') + if alt ~= name then + variantToCanonical[alt] = name + table.insert(nameVariants, alt) + end + end) + + local conditions = ConditionTree(BooleanOperator.all):add{ + ConditionUtil.anyOf(ColumnName('player'), nameVariants), + ConditionNode(ColumnName('date'), Comparator.ge, startDate), + ConditionNode(ColumnName('date'), Comparator.le, endDate), + toTeamFn(ColumnName('toteamtemplate'), teamAliases), + fromTeamFn(ColumnName('fromteamtemplate'), teamAliases), + } + + if status == 'active' then + conditions:add(ConditionUtil.anyOf(ColumnName('role2'), {'', 'Loan'})) + elseif status == 'activeAlt' then + conditions:add(ConditionNode(ColumnName('role1'), Comparator.eq, 'Inactive')) + conditions:add(ConditionNode(ColumnName('role2'), Comparator.eq, '')) + elseif status == 'inactive' then + conditions:add(ConditionNode(ColumnName('role2'), Comparator.eq, 'Inactive')) + end + + local transferData = mw.ext.LiquipediaDB.lpdb('transfer', { + conditions = tostring(conditions), + order = 'date desc', + limit = 5000, + }) + + local datesByPlayer = {} + Array.forEach(transferData, function(row) + local canonical = variantToCanonical[row.player] + if canonical and not datesByPlayer[canonical] then + datesByPlayer[canonical] = DateExt.toYmdInUtc(row.date) + end + end) + return datesByPlayer +end + +---@param players standardPlayer[] +---@param teamAliases string[] +---@return table +function TeamParticipantsRepository.getPlayersDates(players, teamAliases) + local validPlayers = Array.filter(players, function(player) + local pageName = Logic.nilIfEmpty(player.pageName) + return pageName ~= nil and pageName:lower() ~= 'tbd' + end) + + local datesByPlayer = {} + Array.forEach(validPlayers, function(player) + local extradata = player.extradata or {} + datesByPlayer[player.pageName] = { + joinDate = extradata.joinDate, + leaveDate = extradata.leaveDate, + } + end) + + local function tryBatchQuery(playerSubset, status, field) + local needsQuery = Array.filter(playerSubset, function(player) + return Logic.isEmpty(datesByPlayer[player.pageName][field]) + end) + if #needsQuery == 0 then + return + end + local pageNames = Array.map(needsQuery, function(player) return player.pageName end) + local fetched = TeamParticipantsRepository.getPlayerTransferDates(pageNames, teamAliases, status) + Array.forEach(needsQuery, function(player) + if fetched[player.pageName] then + datesByPlayer[player.pageName][field] = fetched[player.pageName] + end + end) + end + + tryBatchQuery(validPlayers, 'active', 'joinDate') + tryBatchQuery(validPlayers, 'activeAlt', 'joinDate') + + local formerPlayers = Array.filter(validPlayers, function(player) + return (player.extradata or {}).status == 'former' + end) + tryBatchQuery(formerPlayers, 'former', 'leaveDate') + tryBatchQuery(formerPlayers, 'inactive', 'leaveDate') + + return datesByPlayer +end + ---@param opponent standardOpponent ---@return placement[] function TeamParticipantsRepository.getPrizepoolRecordsForTeam(opponent)