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',