total block: two-unit countdown (2w 2d … 45s), ticking every second
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 20:59:41 +02:00
parent 520c4c55d3
commit 0d93539944
14 changed files with 151 additions and 20 deletions

View File

@ -17,6 +17,7 @@ from steam_backlog_enforcer._command_locks import (
_MANUAL_LOCK_EXEMPT_COMMANDS,
_TOTAL_BLOCK_EXEMPT_COMMANDS,
)
from steam_backlog_enforcer._remaining import fmt_remaining
from steam_backlog_enforcer._total_block import (
get_total_block_status,
is_total_block_active,
@ -47,11 +48,9 @@ def lock_reason(command: str, config: Config, state: State | None) -> str | None
if command not in UNCONFIGURED_OK and not config.steam_api_key:
return NOT_CONFIGURED_MSG
if is_total_block_active() and command not in _TOTAL_BLOCK_EXEMPT_COMMANDS:
days = get_total_block_status().days_remaining
left = fmt_remaining(get_total_block_status().days_remaining * 86400)
allowed = ", ".join(sorted(_TOTAL_BLOCK_EXEMPT_COMMANDS))
return (
f"Total gaming block active ({days:.1f} day(s) left). Allowed: {allowed}."
)
return f"Total gaming block active ({left} left). Allowed: {allowed}."
if (
state is not None
and is_manual_pick_locked(state)

View File

@ -20,6 +20,7 @@ from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._enforce_loop import ENFORCE_INTERVAL
from steam_backlog_enforcer._playtime import playtime_tick
from steam_backlog_enforcer._playtime_session import new_session
from steam_backlog_enforcer._remaining import fmt_remaining
from steam_backlog_enforcer._total_block import (
get_total_block_status,
is_total_block_active,
@ -40,9 +41,9 @@ def _refuse_under_total_block(when: str) -> int:
Returns:
The exit code to leave with.
"""
days = get_total_block_status().days_remaining
left = fmt_remaining(get_total_block_status().days_remaining * 86400)
_echo(
f"Demo {when}: total gaming block active ({days:.1f} day(s) left). "
f"Demo {when}: total gaming block active ({left} left). "
"Steam is uninstalled and the budget is not counted, so there is "
"nothing for a demo budget to run against."
)

View File

@ -0,0 +1,27 @@
"""Countdown label for time left on a lock, e.g. "2w 2d" or "1h 8m"."""
from __future__ import annotations
import math
# Largest first; weeks are the top unit because months vary in length.
_UNITS = (("w", 7 * 86400), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1))
def fmt_remaining(seconds: float) -> str:
"""Format *seconds* as its two largest units, down to one second.
Rounds up, so the label reads "0s" only once the time is actually up.
Args:
seconds: Time left; negative counts as zero.
Returns:
A label such as "2w 2d", "2d 14h", "2h 20m", "5m 7s" or "45s".
"""
left = max(0, math.ceil(seconds))
for i, (name, size) in enumerate(_UNITS[:-1]):
if left >= size:
sub_name, sub_size = _UNITS[i + 1]
return f"{left // size}{name} {left % size // sub_size}{sub_name}"
return f"{left}s"

View File

@ -23,6 +23,7 @@ from steam_backlog_enforcer._command_locks import (
_TOTAL_BLOCK_EXEMPT_COMMANDS,
)
from steam_backlog_enforcer._manual_pick_lifecycle import manual_pick_age_days
from steam_backlog_enforcer._remaining import fmt_remaining
from steam_backlog_enforcer._total_block import (
TotalBlockStatus,
get_total_block_status,
@ -49,7 +50,7 @@ def _show_total_block_lock_message(status: TotalBlockStatus) -> None:
if status.until is not None:
_echo(f"\nBlocked until: {status.until.strftime('%Y-%m-%d %H:%M UTC')}")
_echo(f"Days remaining: {status.days_remaining:.1f}")
_echo(f"Time remaining: {fmt_remaining(status.days_remaining * 86400)}")
_echo(
"\nSteam has been uninstalled, all known game/launcher processes are"

View File

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import active_manual_picks
from steam_backlog_enforcer._manual_pick_lifecycle import manual_pick_age_days
from steam_backlog_enforcer._pick_completion import report_completion
from steam_backlog_enforcer._remaining import fmt_remaining
from steam_backlog_enforcer._snapshot import require_snapshot_games
from steam_backlog_enforcer._total_block import get_total_block_status
from steam_backlog_enforcer.game_install import (
@ -31,7 +32,8 @@ def cmd_status(config: Config, state: State) -> None:
_echo("*** TOTAL GAMING BLOCK ACTIVE ***")
if total_block.until is not None:
_echo(f"Blocked until: {total_block.until.strftime('%Y-%m-%d %H:%M UTC')}")
_echo(f"Days remaining: {total_block.days_remaining:.1f}\n")
left = fmt_remaining(total_block.days_remaining * 86400)
_echo(f"Time remaining: {left}\n")
if state.current_app_id:
_echo(

View File

@ -45,7 +45,7 @@ class TestLockReason:
with _block(active=True):
reason = _gate.lock_reason("scan", _CONFIGURED, State())
assert reason is not None
assert "Total gaming block active (3.5 day(s) left)" in reason
assert "Total gaming block active (3d 12h left)" in reason
assert "Allowed:" in reason
def test_exempt_commands_pass_the_total_block(self) -> None:

View File

@ -29,7 +29,7 @@ class TestRunDemo:
):
assert run_demo(Config()) == EXIT_REFUSED
tick.assert_not_called()
assert "Demo cannot start: total gaming block active (2.0 day(s) left)" in (
assert "Demo cannot start: total gaming block active (2d 0h left)" in (
_echoed(echo)
)
@ -62,6 +62,4 @@ class TestRunDemo:
):
assert run_demo(Config()) == EXIT_REFUSED
tick.assert_called_once()
assert "Demo stopped: total gaming block active (1.0 day(s) left)" in (
_echoed(echo)
)
assert "Demo stopped: total gaming block active (1d 0h left)" in (_echoed(echo))

View File

@ -133,7 +133,7 @@ class TestShowTotalBlockLockMessage:
_show_total_block_lock_message(ACTIVE_STATUS)
output = " ".join(str(c) for c in mock_echo.call_args_list)
assert "TOTAL GAMING BLOCK ACTIVE" in output
assert "Days remaining" in output
assert "Time remaining: 23h 2m" in output
def test_lists_exempt_commands(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:

View File

@ -138,7 +138,7 @@ class TestCmdStatusTotalBlock:
cmd_status(Config(), State())
output = " ".join(str(c) for c in mock_echo.call_args_list)
assert "TOTAL GAMING BLOCK ACTIVE" in output
assert "Days remaining" in output
assert "Time remaining: 23h 2m" in output
def test_no_total_block_section_when_inactive(self) -> None:
with (
@ -166,7 +166,7 @@ class TestCmdStatusTotalBlock:
cmd_status(Config(), State())
output = " ".join(str(c) for c in mock_echo.call_args_list)
assert "TOTAL GAMING BLOCK ACTIVE" in output
assert "Days remaining" not in output
assert "Time remaining" not in output
# ──────────────────────────────────────────────────────────────

View File

@ -0,0 +1,31 @@
"""Tests for the two-unit countdown label."""
from __future__ import annotations
import pytest
from steam_backlog_enforcer._remaining import fmt_remaining
_H = 3600
_D = 24 * _H
@pytest.mark.parametrize(
("seconds", "label"),
[
(16 * _D, "2w 2d"),
(2 * _D + 14 * _H, "2d 14h"),
(2 * _H + 20 * 60, "2h 20m"),
(5 * 60 + 7, "5m 7s"),
(45, "45s"),
(2 * _D, "2d 0h"),
(60 * _D, "8w 4d"),
(_H + 8 * 60 + 0.3, "1h 8m"),
(59.2, "1m 0s"),
(0, "0s"),
(-5, "0s"),
],
)
def test_fmt_remaining(seconds: float, label: str) -> None:
"""Two largest units, rounded up, weeks down to seconds."""
assert fmt_remaining(seconds) == label

View File

@ -7,6 +7,7 @@ import {
fmtHours,
fmtHoursPrecise,
fmtPlaytime,
fmtRemaining,
isoDate,
} from './format'
@ -89,3 +90,25 @@ describe('fmtAgo', () => {
expect(fmtAgo('not a date', now)).toBe('unknown')
})
})
describe('fmtRemaining', () => {
const H = 3600
const D = 24 * H
it('shows the two largest units, weeks down to seconds', () => {
expect(fmtRemaining(16 * D)).toBe('2w 2d')
expect(fmtRemaining(2 * D + 14 * H)).toBe('2d 14h')
expect(fmtRemaining(2 * H + 20 * 60)).toBe('2h 20m')
expect(fmtRemaining(5 * 60 + 7)).toBe('5m 7s')
expect(fmtRemaining(45)).toBe('45s')
})
it('keeps a zero second unit and caps at weeks', () => {
expect(fmtRemaining(2 * D)).toBe('2d 0h')
expect(fmtRemaining(60 * D)).toBe('8w 4d')
})
it('rounds up so it reaches 0s only when the time is up', () => {
expect(fmtRemaining(H + 8 * 60 + 0.3)).toBe('1h 8m')
expect(fmtRemaining(59.2)).toBe('1m 0s')
expect(fmtRemaining(0)).toBe('0s')
expect(fmtRemaining(-5)).toBe('0s')
})
})

View File

@ -62,3 +62,20 @@ export function fmtAgo(iso: string, now: Date = new Date()): string {
if (seconds < 10) return 'just now'
return `${fmtDuration(seconds)} ago`
}
// Largest first; weeks are the top unit because months vary in length.
const REMAINING_UNITS: [string, number][] = [['w', 604_800], ['d', 86_400], ['h', 3600], ['m', 60], ['s', 1]]
/** Time left as its two largest units: "2w 2d", "2d 14h", "2h 20m", "5m 7s", "45s".
* Rounds up, so it reads "0s" only once the time is actually up. */
export function fmtRemaining(seconds: number): string {
const left = Math.max(0, Math.ceil(seconds))
for (let i = 0; i < REMAINING_UNITS.length - 1; i += 1) {
const [name, size] = REMAINING_UNITS[i]
if (left >= size) {
const [subName, subSize] = REMAINING_UNITS[i + 1]
return `${Math.floor(left / size)}${name} ${Math.floor((left % size) / subSize)}${subName}`
}
}
return `${left}s`
}

View File

@ -1,4 +1,4 @@
import { screen } from '@testing-library/react'
import { act, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makeBudget, makeBudgetToday } from '../test/factories'
import { makeJob, makeSpec, makeStatus } from '../test/fixtures'
@ -62,10 +62,33 @@ describe('Dashboard', () => {
expect(await screen.findByText('Nothing assigned')).toBeInTheDocument()
expect(fact('Installed')).toHaveTextContent('No — install it from Library')
expect(fact('Store')).toHaveTextContent('Unblocked')
expect(fact('Total block')).toHaveTextContent('5 days left')
expect(fact('Total block')).toHaveTextContent('5d 0h left')
expect(fact('Manual picks')).toHaveTextContent('Alpha (2 d), Beta (9 d)')
})
it('counts the total block down from until, every second', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true, now: Date.parse('2026-10-23T10:00:00Z') })
try {
show({
'GET /api/status': makeStatus({
total_block: { active: true, days_remaining: 0, until: '2026-10-23T11:08:02Z' },
}),
})
expect(await screen.findByText('1h 8m left')).toBeInTheDocument()
// The jump alone re-renders nothing; the 1 s tick picks it up, then
// every further tick takes exactly one second off.
act(() => vi.setSystemTime(Date.parse('2026-10-23T11:07:58Z')))
expect(fact('Total block')).toHaveTextContent('1h 8m left')
act(() => vi.advanceTimersByTime(1000))
const first = Number(/^(\d)s left$/.exec(fact('Total block').textContent ?? '')?.[1])
expect([3, 4]).toContain(first)
act(() => vi.advanceTimersByTime(1000))
expect(fact('Total block')).toHaveTextContent(`${first - 1}s left`)
} finally {
vi.useRealTimers()
}
})
it('shows loading, then an error', async () => {
show({ 'GET /api/status': () => new Promise(() => {}) })
expect(await screen.findByText(/Loading status/)).toBeInTheDocument()

View File

@ -4,11 +4,20 @@ import type { FastInstallRun, StatusPayload } from '../api/contract'
import { q } from '../api/queries'
import { CommandButton } from '../commands/CommandCard'
import { commandLabel } from '../commands/catalog'
import { fmtDuration } from '../format'
import { fmtDuration, fmtRemaining } from '../format'
import { ProgressBar, StateBadge } from '../jobs/ProgressBar'
import { ago } from '../ui/format'
import { Empty, ErrorNotice, Loading } from '../ui/Notice'
import { Card, Facts, PageHead } from '../ui/Page'
import { useNow } from '../ui/useNow'
/** Ticks every second from `until`; the server's `days_remaining` (rounded to
* 0.1 d) is only the fallback when no end time is known. */
function TotalBlockLeft({ block }: { block: StatusPayload['total_block'] }) {
const now = useNow(1000)
const seconds = block.until ? (Date.parse(block.until) - now) / 1000 : block.days_remaining * 86_400
return <span className="text-danger">{fmtRemaining(seconds)} left</span>
}
function StatusCard({ s }: { s: StatusPayload }) {
return (
@ -22,7 +31,7 @@ function StatusCard({ s }: { s: StatusPayload }) {
['Installed games', s.installed_count],
[
'Total block',
s.total_block.active ? <span className="text-danger">{s.total_block.days_remaining} days left</span> : 'Off',
s.total_block.active ? <TotalBlockLeft block={s.total_block} /> : 'Off',
],
[
'Manual picks',