feat(race): add supports_countdown to ContestListResult

Problem: `:CP race` on platforms without future contests (CSES, USACO)
wastes time fetching the full contest list only to discover there is
no `start_time`. The error message is also uninformative.

Solution: Add `supports_countdown` bool to `ContestListResult` (default
`True`). CSES and USACO set it to `False`. Cache the flag per-platform
so subsequent calls skip the fetch entirely. `race.lua` checks the
cached value first, then the scraper result, and shows
`"<Platform> does not support :CP race"` instead of a generic error.
This commit is contained in:
Barrett Ruth 2026-03-06 18:01:08 -05:00
parent 592f977296
commit 4e709c8470
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
7 changed files with 62 additions and 16 deletions

View file

@ -392,7 +392,8 @@ end
---@param platform string ---@param platform string
---@param contests ContestSummary[] ---@param contests ContestSummary[]
function M.set_contest_summaries(platform, contests) ---@param opts? { supports_countdown?: boolean }
function M.set_contest_summaries(platform, contests, opts)
cache_data[platform] = cache_data[platform] or {} cache_data[platform] = cache_data[platform] or {}
for _, contest in ipairs(contests) do for _, contest in ipairs(contests) do
cache_data[platform][contest.id] = cache_data[platform][contest.id] or {} cache_data[platform][contest.id] = cache_data[platform][contest.id] or {}
@ -405,9 +406,22 @@ function M.set_contest_summaries(platform, contests)
end end
end end
if opts and opts.supports_countdown ~= nil then
cache_data[platform].supports_countdown = opts.supports_countdown
end
M.save() M.save()
end end
---@param platform string
---@return boolean?
function M.get_supports_countdown(platform)
if not cache_data[platform] then
return nil
end
return cache_data[platform].supports_countdown
end
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@return integer? ---@return integer?

View file

@ -48,8 +48,10 @@ function M.get_platform_contests(platform, refresh)
{ level = vim.log.levels.INFO, override = true, sync = true } { level = vim.log.levels.INFO, override = true, sync = true }
) )
local contests = scraper.scrape_contest_list(platform) local result = scraper.scrape_contest_list(platform)
cache.set_contest_summaries(platform, contests) local contests = result and result.contests or {}
local sc = result and result.supports_countdown
cache.set_contest_summaries(platform, contests, { supports_countdown = sc })
picker_contests = cache.get_contest_summaries(platform) picker_contests = cache.get_contest_summaries(platform)
logger.log( logger.log(

View file

@ -35,20 +35,36 @@ function M.start(platform, contest_id, language)
end end
cache.load() cache.load()
local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
local cached_countdown = cache.get_supports_countdown(platform)
if cached_countdown == false then
logger.log(('%s does not support :CP race'):format(display), { level = vim.log.levels.ERROR })
return
end
local start_time = cache.get_contest_start_time(platform, contest_id) local start_time = cache.get_contest_start_time(platform, contest_id)
if not start_time then if not start_time then
logger.log('Fetching contest list...', { level = vim.log.levels.INFO, override = true }) logger.log('Fetching contest list...', { level = vim.log.levels.INFO, override = true, sync = true })
local contests = scraper.scrape_contest_list(platform) local result = scraper.scrape_contest_list(platform)
if contests and #contests > 0 then if result then
cache.set_contest_summaries(platform, contests) local sc = result.supports_countdown
start_time = cache.get_contest_start_time(platform, contest_id) if sc == false then
cache.set_contest_summaries(platform, result.contests or {}, { supports_countdown = false })
logger.log(('%s does not support :CP race'):format(display), { level = vim.log.levels.ERROR })
return
end
if result.contests and #result.contests > 0 then
cache.set_contest_summaries(platform, result.contests, { supports_countdown = sc })
start_time = cache.get_contest_start_time(platform, contest_id)
end
end end
end end
if not start_time then if not start_time then
logger.log( logger.log(
('No start time found for %s contest %s'):format( ('No start time found for %s contest "%s"'):format(
constants.PLATFORM_DISPLAY_NAMES[platform] or platform, constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
contest_id contest_id
), ),

View file

@ -257,9 +257,12 @@ function M.scrape_contest_list(platform)
), ),
{ level = vim.log.levels.ERROR } { level = vim.log.levels.ERROR }
) )
return {} return nil
end end
return result.data.contests return {
contests = result.data.contests,
supports_countdown = result.data.supports_countdown ~= false,
}
end end
---@param platform string ---@param platform string

View file

@ -228,9 +228,13 @@ class CSESScraper(BaseScraper):
cats = parse_categories(html) cats = parse_categories(html)
if not cats: if not cats:
return ContestListResult( return ContestListResult(
success=False, error=f"{self.platform_name}: No contests found" success=False,
error=f"{self.platform_name}: No contests found",
supports_countdown=False,
) )
return ContestListResult(success=True, error="", contests=cats) return ContestListResult(
success=True, error="", contests=cats, supports_countdown=False
)
async def login(self, credentials: dict[str, str]) -> LoginResult: async def login(self, credentials: dict[str, str]) -> LoginResult:
username = credentials.get("username", "") username = credentials.get("username", "")

View file

@ -50,6 +50,7 @@ class MetadataResult(ScrapingResult):
class ContestListResult(ScrapingResult): class ContestListResult(ScrapingResult):
contests: list[ContestSummary] = Field(default_factory=list) contests: list[ContestSummary] = Field(default_factory=list)
supports_countdown: bool = True
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View file

@ -337,10 +337,16 @@ class USACOScraper(BaseScraper):
contests.extend(await coro) contests.extend(await coro)
if not contests: if not contests:
return self._contests_error("No contests found") return ContestListResult(
return ContestListResult(success=True, error="", contests=contests) success=False, error="No contests found", supports_countdown=False
)
return ContestListResult(
success=True, error="", contests=contests, supports_countdown=False
)
except Exception as e: except Exception as e:
return self._contests_error(str(e)) return ContestListResult(
success=False, error=str(e), supports_countdown=False
)
async def stream_tests_for_category_async(self, category_id: str) -> None: async def stream_tests_for_category_async(self, category_id: str) -> None:
month_year, division = _parse_contest_id(category_id) month_year, division = _parse_contest_id(category_id)