diff --git a/steam_backlog_enforcer/_command_gate.py b/steam_backlog_enforcer/_command_gate.py
index 36a833b..39810eb 100644
--- a/steam_backlog_enforcer/_command_gate.py
+++ b/steam_backlog_enforcer/_command_gate.py
@@ -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)
diff --git a/steam_backlog_enforcer/_enforce_demo.py b/steam_backlog_enforcer/_enforce_demo.py
index b49c235..1c06ed6 100644
--- a/steam_backlog_enforcer/_enforce_demo.py
+++ b/steam_backlog_enforcer/_enforce_demo.py
@@ -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."
)
diff --git a/steam_backlog_enforcer/_remaining.py b/steam_backlog_enforcer/_remaining.py
new file mode 100644
index 0000000..6f122f1
--- /dev/null
+++ b/steam_backlog_enforcer/_remaining.py
@@ -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"
diff --git a/steam_backlog_enforcer/main/_shared.py b/steam_backlog_enforcer/main/_shared.py
index 9536893..4433618 100644
--- a/steam_backlog_enforcer/main/_shared.py
+++ b/steam_backlog_enforcer/main/_shared.py
@@ -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"
diff --git a/steam_backlog_enforcer/main/status.py b/steam_backlog_enforcer/main/status.py
index 94c630b..eaa3d8e 100644
--- a/steam_backlog_enforcer/main/status.py
+++ b/steam_backlog_enforcer/main/status.py
@@ -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(
diff --git a/steam_backlog_enforcer/tests/test_command_gate.py b/steam_backlog_enforcer/tests/test_command_gate.py
index 2508906..430e00d 100644
--- a/steam_backlog_enforcer/tests/test_command_gate.py
+++ b/steam_backlog_enforcer/tests/test_command_gate.py
@@ -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:
diff --git a/steam_backlog_enforcer/tests/test_enforce_demo.py b/steam_backlog_enforcer/tests/test_enforce_demo.py
index 0969540..63341bc 100644
--- a/steam_backlog_enforcer/tests/test_enforce_demo.py
+++ b/steam_backlog_enforcer/tests/test_enforce_demo.py
@@ -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))
diff --git a/steam_backlog_enforcer/tests/test_main_shared.py b/steam_backlog_enforcer/tests/test_main_shared.py
index 3798951..7cd47b0 100644
--- a/steam_backlog_enforcer/tests/test_main_shared.py
+++ b/steam_backlog_enforcer/tests/test_main_shared.py
@@ -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:
diff --git a/steam_backlog_enforcer/tests/test_main_status.py b/steam_backlog_enforcer/tests/test_main_status.py
index 78ebc06..9ec9e99 100644
--- a/steam_backlog_enforcer/tests/test_main_status.py
+++ b/steam_backlog_enforcer/tests/test_main_status.py
@@ -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
# ──────────────────────────────────────────────────────────────
diff --git a/steam_backlog_enforcer/tests/test_remaining.py b/steam_backlog_enforcer/tests/test_remaining.py
new file mode 100644
index 0000000..1501eb8
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_remaining.py
@@ -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
diff --git a/web/src/format.test.ts b/web/src/format.test.ts
index 8aa8a1a..2a11abb 100644
--- a/web/src/format.test.ts
+++ b/web/src/format.test.ts
@@ -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')
+ })
+})
diff --git a/web/src/format.ts b/web/src/format.ts
index 0c18f51..fa4d1f7 100644
--- a/web/src/format.ts
+++ b/web/src/format.ts
@@ -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`
+}
diff --git a/web/src/screens/Dashboard.test.tsx b/web/src/screens/Dashboard.test.tsx
index 533c25d..f4a11f7 100644
--- a/web/src/screens/Dashboard.test.tsx
+++ b/web/src/screens/Dashboard.test.tsx
@@ -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()
diff --git a/web/src/screens/Dashboard.tsx b/web/src/screens/Dashboard.tsx
index 3cc47fe..c33e50b 100644
--- a/web/src/screens/Dashboard.tsx
+++ b/web/src/screens/Dashboard.tsx
@@ -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 {fmtRemaining(seconds)} left
+}
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 ? {s.total_block.days_remaining} days left : 'Off',
+ s.total_block.active ? : 'Off',
],
[
'Manual picks',