web: migrate status page from React to Astro

One static Astro page, vanilla TS poller and renderer,
@kuhyx/web-ui 0.5.0 for tokens only. Same web/dist output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 20:12:24 +01:00
parent 6cd21296a9
commit f3bd94f646
45 changed files with 3836 additions and 1502 deletions

1
.gitignore vendored
View File

@ -38,6 +38,7 @@ screen_locker/early_bird_pending.json
# Web status UI (web/) build and tooling output.
web/node_modules/
web/dist/
web/.astro/
web/coverage/
web/node_modules
web/dist

View File

@ -324,14 +324,14 @@ repos:
name: eslint (status web UI)
entry: pnpm --dir web run lint
language: system
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
files: '^web/(src/.*\.(ts|astro)|eslint\.config\.js|package\.json)$'
pass_filenames: false
require_serial: true
- id: web-typecheck
name: tsc --build (status web UI)
name: astro check + build (status web UI)
entry: pnpm --dir web run build
language: system
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
files: '^web/(src/.*\.(ts|astro)|tsconfig\.json|astro\.config\.mjs|vitest\.config\.ts)$'
pass_filenames: false
require_serial: true
- id: web-coverage

View File

@ -1,60 +0,0 @@
# Migrate screen-locker/web to Astro (no React)
## what
The status page in `web/` (now/health/why cards, polling `/api/status`)
becomes one Astro 7 static page. Its `<script>` polls and renders in vanilla
TS. `python -m screen_locker._web_server` keeps serving `web/dist` on
127.0.0.1:8770.
## steps
1. Do `~/src/utils/web_ui/TODO-astro-port.md` first. Then add `@kuhyx/web-ui`
at the new tag, for `tokens.css` only. This repo does not use it yet.
2. Use a single page, `src/pages/index.astro`. There is no routing today, so
`_web_server.py` and its SPA fallback need no change.
3. Rewrite the four React files:
- `App.tsx` becomes `index.astro` plus `layouts/Base.astro`.
- `components/NowCard.tsx`, `HealthCard.tsx` and `WhyTable.tsx` become `.astro`
shells with `data-*` slots, filled by a renderer in `src/render.ts`.
- Hook `useStatusPoll.ts` becomes `src/poll.ts`, a plain poller class with
`start()`, `stop()` and a callback. It stops on `visibilitychange`
hidden, as now.
- `api.ts`, `format.ts` and `types.ts` stay as they are.
4. The `<script>` in `index.astro` only wires `poll.ts` to `render.ts`. The
logic stays in `.ts` files so the coverage thresholds still apply.
5. Dependencies, from `web/`, exact pins via pnpm:
- Add `astro@7.4.4`, `@astrojs/check@0.9.12` and `eslint-plugin-astro@3.2.1`.
- Remove `react`, `react-dom`, `@vitejs/plugin-react`,
`@testing-library/react`, `eslint-plugin-react-hooks` and
`eslint-plugin-react-refresh`.
- Drop direct `vite` (Astro brings `^8.3.5`).
- Fold `tsconfig.app.json`, `tsconfig.node.json` and `tsconfig.test.json`
into Astro's `tsconfig` preset where possible.
6. Tests. Use vitest 5.0.5 with `getViteConfig()`:
- `render.ts` and `poll.ts` are tested in jsdom with `@testing-library/dom`
and fake timers.
- `src/test/factories.ts` stays.
- One Container API test checks that the page shell renders.
7. `build` becomes `astro check && astro build`, output `web/dist`.
8. There is no `[agent_ready]` table. It is a loopback status page, so skip
AgentReady.
## done
Run from `web/`:
`pnpm install --frozen-lockfile && pnpm lint && pnpm coverage && pnpm build`
Then:
`! grep -q '"react"' package.json && ! grep -qE '"(react-dom|@vitejs/plugin-react)"' package.json && ! find src -name '*.tsx' | grep -q .`
Both exit 0. The Python web-server tests still pass:
`python -m pytest -q screen_locker/tests/test_web_server.py screen_locker/tests/test_web_server_part2.py`
## verify
Run `systemctl --user restart screen-locker-web.service`. Then:
- `curl -s http://127.0.0.1:8770/ | grep -q '<h1'`
- Use a headless chromium at 4K and at mobile size. All three cards fill
within one poll, and there are no console errors.
## read first
- `~/src/utils/web_ui/TODO-astro-port.md` (do it first)
- `web/src/App.tsx`, `web/src/useStatusPoll.ts`, `web/src/api.ts`
- `screen_locker/_web_server.py`, `screen-locker-web.service`
REMOVE ME AFTER FINISH

16
web/astro.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'astro/config'
// The Python API (`screen-locker-web`) runs on 127.0.0.1:8770 — 8000 belongs to
// steam-backlog-enforcer's own UI. In dev, Astro serves the UI and proxies
// `/api/*` to it, so there are no CORS concerns. In production the Python
// server serves the built bundle from `web/dist`.
export default defineConfig({
output: 'static',
vite: {
server: {
proxy: {
'/api': 'http://127.0.0.1:8770',
},
},
},
})

View File

@ -1,25 +1,21 @@
import js from '@eslint/js'
import astro from 'eslint-plugin-astro'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
// Deliberately stricter than steam-backlog-enforcer/web, which this was copied
// from: that one extends only `tseslint.configs.recommended` and so runs no
// type-aware rules at all. `strictTypeChecked` needs `projectService`, which is
// why tsconfig.test.json exists — test files are outside tsconfig.app.json's
// program and would otherwise be unlintable.
// type-aware rules at all. `strictTypeChecked` needs `projectService`, so the
// tests live inside tsconfig.json's `src` include and are lintable.
export default defineConfig([
globalIgnores(['dist', 'coverage']),
globalIgnores(['dist', 'coverage', '.astro']),
{
files: ['**/*.{ts,tsx}'],
files: ['**/*.ts'],
extends: [
js.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
@ -29,4 +25,5 @@ export default defineConfig([
},
},
},
...astro.configs.recommended,
])

View File

@ -5,38 +5,33 @@
"packageManager": "pnpm@12.16.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"dev": "astro dev",
"build": "astro check && astro build",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit false --emitDeclarationOnly false",
"preview": "vite preview",
"typecheck": "astro check",
"preview": "astro preview",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.5.0&path:/web_ui",
"astro": "7.4.4"
},
"devDependencies": {
"@astrojs/check": "0.9.12",
"@eslint/js": "10.0.1",
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@types/node": "26.6.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"@vitest/coverage-v8": "5.0.5",
"eslint": "10.14.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"globals": "17.13.0",
"eslint-plugin-astro": "3.2.1",
"globals": "17.14.0",
"jsdom": "30.1.3",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vite": "8.3.5",
"vitest": "5.0.5"
}
}

File diff suppressed because it is too large Load Diff

2
web/pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
minimumReleaseAgeExclude:
- globals@17.14.0

View File

@ -1,53 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import { makeDecisions, makeHealth, makeStatus } from './test/factories'
function stubEndpoints(overrides: Record<string, unknown> = {}): void {
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
const body =
url.startsWith('/api/status')
? (overrides.status ?? makeStatus())
: url.startsWith('/api/health')
? (overrides.health ?? makeHealth())
: (overrides.decisions ?? makeDecisions())
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) })
}),
)
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('App', () => {
it('renders all three views once the endpoints answer', async () => {
stubEndpoints()
render(<App />)
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Now' })).toBeInTheDocument()
})
expect(screen.getByRole('heading', { name: 'Health' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Why' })).toBeInTheDocument()
})
it('shows a loading line before the first status arrives', () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => undefined)))
render(<App />)
expect(screen.getByText('Reading status…')).toBeInTheDocument()
})
it('surfaces a failing endpoint instead of silently showing nothing', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Unavailable' }),
)
render(<App />)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
expect(screen.getByRole('alert')).toHaveTextContent('API returned 503')
})
})

View File

@ -1,43 +0,0 @@
import { useCallback } from 'react'
import { HealthCard } from './components/HealthCard'
import { NowCard } from './components/NowCard'
import { WhyTable } from './components/WhyTable'
import { fetchDecisions, fetchHealth, fetchStatus } from './api'
import { usePoll } from './useStatusPoll'
const DECISION_LIMIT = 200
export function App() {
// Stable identities: usePoll re-subscribes whenever the fetcher changes, and
// an inline arrow would restart every interval on each render.
const decisionFetcher = useCallback(() => fetchDecisions(DECISION_LIMIT), [])
const status = usePoll(fetchStatus)
const decisions = usePoll(decisionFetcher)
const health = usePoll(fetchHealth)
const errors = [status.error, decisions.error, health.error].filter(
(e): e is string => e !== null,
)
return (
<main>
<h1>Screen locker</h1>
{errors.length > 0 && (
<div className="error" role="alert">
{/* Shown rather than swallowed: a UI that silently keeps rendering
stale data is the same failure the locker itself had. */}
{errors.map((message) => (
<div key={message}>{message}</div>
))}
</div>
)}
{status.data === null ? (
<p className="muted">Reading status…</p>
) : (
<NowCard status={status.data} />
)}
{health.data !== null && <HealthCard health={health.data} />}
{decisions.data !== null && <WhyTable decisions={decisions.data} />}
</main>
)
}

View File

@ -0,0 +1,4 @@
<section class="card" aria-labelledby="health-heading" data-card="health" hidden>
<h2 id="health-heading">Health</h2>
<div data-slot></div>
</section>

View File

@ -1,61 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { HealthCard } from './HealthCard'
import { makeHealth } from '../test/factories'
describe('HealthCard', () => {
it('reports an armed locker', () => {
render(<HealthCard health={makeHealth()} />)
expect(screen.getByText('armed')).toBeInTheDocument()
expect(screen.getByText('enabled and scheduled')).toBeInTheDocument()
})
it('says an unchecked arming state is NOT a pass', () => {
render(
<HealthCard
health={makeHealth({ timers_checked: false, timers: [], armed: false })}
/>,
)
expect(screen.getByText(/This is not a pass/)).toBeInTheDocument()
})
it('shows the disarm marker when enforcement is switched off', () => {
render(<HealthCard health={makeHealth({ disarmed: true, armed: false })} />)
expect(screen.getByText(/present at/)).toBeInTheDocument()
expect(screen.getByText('NOT armed')).toBeInTheDocument()
})
it('shows a disarmed timer with its own reason', () => {
render(
<HealthCard
health={makeHealth({
armed: false,
timers: [
{
name: 'workout-locker.timer',
enabled: false,
scheduled: false,
armed: false,
describe: 'DISARMED workout-locker.timer: not enabled',
},
],
})}
/>,
)
expect(
screen.getByText('DISARMED workout-locker.timer: not enabled'),
).toBeInTheDocument()
})
it('renders unknown ages rather than hiding them', () => {
render(
<HealthCard
health={makeHealth({
log_age_seconds: null,
last_decision_age_seconds: null,
})}
/>,
)
expect(screen.getAllByText('unknown ago')).toHaveLength(2)
})
})

View File

@ -1,56 +0,0 @@
import { formatAge } from '../format'
import type { HealthPayload } from '../types'
interface Props {
readonly health: HealthPayload
}
/**
* Whether the locker could actually fire.
*
* Every row here is something that once went wrong silently: a timer systemd
* deleted to break an ordering cycle, a disarm marker nobody removed, a log
* that stopped being written. Absences are rendered as values so they cannot
* be mistaken for good news.
*/
export function HealthCard({ health }: Props) {
const armedClass = health.armed ? 'state-ok' : 'state-lock'
return (
<section className="card" aria-labelledby="health-heading">
<h2 id="health-heading">Health</h2>
{!health.timers_checked && (
<p className="state-warn">
{'systemctl unavailable — arming was NOT checked. This is not a pass.'}
</p>
)}
<div className="row">
<span>Enforcement</span>
<span className={armedClass}>{health.armed ? 'armed' : 'NOT armed'}</span>
</div>
{health.disarmed && (
<div className="row">
<span>Disarm marker</span>
<span className="state-lock">{`present at ${health.disarm_marker}`}</span>
</div>
)}
{health.timers.map((timer) => (
<div className="row" key={timer.name}>
<span>{timer.name}</span>
<span className={timer.armed ? 'state-ok' : 'state-lock'}>
{timer.armed ? 'enabled and scheduled' : timer.describe}
</span>
</div>
))}
<div className="row">
<span>Workout log written</span>
<span className="muted">{`${formatAge(health.log_age_seconds)} ago`}</span>
</div>
<div className="row">
<span>Last decision recorded</span>
<span className="muted">
{`${formatAge(health.last_decision_age_seconds)} ago`}
</span>
</div>
</section>
)
}

View File

@ -0,0 +1,4 @@
<section class="card" aria-labelledby="now-heading" data-card="now" hidden>
<h2 id="now-heading">Now</h2>
<div data-slot></div>
</section>

View File

@ -1,146 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { NowCard } from './NowCard'
import { makeStatus } from '../test/factories'
import type { StatusPayload } from '../types'
describe('NowCard', () => {
it('shows the cut budget and says why when no workout was logged', () => {
render(<NowCard status={makeStatus()} />)
expect(screen.getByText('No lock')).toBeInTheDocument()
expect(screen.getByText('6h')).toBeInTheDocument()
expect(
screen.getByText('cut from 8h — no workout logged today'),
).toBeInTheDocument()
})
it('shows the earned budget once a workout is logged', () => {
const status = makeStatus({
gaming: {
workout_today: true,
credits_today: 1,
reason: '1 counted workout(s) logged today',
},
})
render(<NowCard status={status} />)
expect(screen.getByText('8h')).toBeInTheDocument()
expect(
screen.getByText('earned by a workout logged today'),
).toBeInTheDocument()
})
it('says the lock would fire when it would', () => {
const base = makeStatus()
render(
<NowCard
status={{
...base,
compliance_state: 'lock',
snapshot: {
...base.snapshot,
lock_explanation: {
...base.snapshot.lock_explanation,
fired: true,
stage: 'would_lock',
reason: 'No skip condition applies.',
},
},
}}
/>,
)
expect(screen.getByText('Lock would fire')).toBeInTheDocument()
})
it('counts down the remaining workouts', () => {
render(<NowCard status={makeStatus()} />)
expect(screen.getByText(/3\/5 workouts — 2 to go/)).toBeInTheDocument()
})
it('drops the countdown once the week is met', () => {
const base = makeStatus()
render(
<NowCard
status={{
...base,
snapshot: {
...base.snapshot,
week: { ...base.snapshot.week, counted_count: 5, remaining: 0 },
},
}}
/>,
)
expect(screen.getByText(/5\/5 workouts$/)).toBeInTheDocument()
})
it('flags degraded sources rather than rendering a confident answer', () => {
const base = makeStatus()
render(
<NowCard
status={{
...base,
snapshot: {
...base.snapshot,
lock_explanation: {
...base.snapshot.lock_explanation,
sources_degraded: true,
},
},
}}
/>,
)
expect(screen.getByText(/degraded/)).toBeInTheDocument()
})
/**
* "Lock would fire" is a statement about the decision, not about whether
* anything is enforcing it. On 2026-08-30 the page said exactly that while
* the machine had just rebooted and no locker process existed.
*/
const firing = (over: Partial<StatusPayload> = {}) => {
const base = makeStatus()
return {
...base,
snapshot: {
...base.snapshot,
lock_explanation: { ...base.snapshot.lock_explanation, fired: true },
},
...over,
}
}
it('says when a decided lock is queued behind another holder', () => {
render(
<NowCard
status={firing({
queue_wait: {
blocked_by: ['wake_alarm'],
elapsed_seconds: 10716,
updated: '2026-08-30T09:01:05+00:00',
},
locker_running: true,
})}
/>,
)
expect(screen.getByText(/waiting for/)).toBeInTheDocument()
expect(screen.getByText(/179 min so far/)).toBeInTheDocument()
expect(screen.getByText(/not unlocked/)).toBeInTheDocument()
})
it('says when nothing is enforcing a lock it says would fire', () => {
render(<NowCard status={firing({ locker_running: false })} />)
expect(
screen.getByText(/No locker run is in progress right now/),
).toBeInTheDocument()
})
it('stays quiet when a run is up and unblocked', () => {
render(<NowCard status={firing({ locker_running: true })} />)
expect(screen.queryByText(/No locker run is in progress/)).toBeNull()
expect(screen.queryByText(/waiting for/)).toBeNull()
})
it('says nothing about enforcement when no lock is due', () => {
render(<NowCard status={makeStatus({ locker_running: false })} />)
expect(screen.queryByText(/No locker run is in progress/)).toBeNull()
})
})

View File

@ -1,78 +0,0 @@
import { budgetHours } from '../format'
import type { StatusPayload } from '../types'
interface Props {
readonly status: StatusPayload
}
/** The decision as it stands right now, in words rather than slugs. */
export function NowCard({ status }: Props) {
const {
snapshot,
gaming,
compliance_state: state,
queue_wait: queued,
locker_running: running,
} = status
const { week, lock_explanation: lock } = snapshot
const hours = budgetHours(gaming.workout_today)
return (
<section className="card" aria-labelledby="now-heading">
<h2 id="now-heading">Now</h2>
<p className={`headline state-${state}`}>
{lock.fired ? 'Lock would fire' : 'No lock'}
</p>
<p className="muted">{lock.reason}</p>
{lock.fired && queued !== null && (
<p className="state-lock">
A locker run has already decided to lock and is waiting for{' '}
{queued.blocked_by.join(', ')} to release the screen (
{Math.round(queued.elapsed_seconds / 60)} min so far). The screen is
held by {queued.blocked_by.join(', ')}, not unlocked.
</p>
)}
{lock.fired && queued === null && running === false && (
<p className="state-lock">
No locker run is in progress right now, so nothing is enforcing this.
The next timer tick will start one.
</p>
)}
<div className="row">
<span>This week</span>
<span>
{week.counted_count}/{week.minimum} workouts
{week.remaining > 0 ? ` — ${String(week.remaining)} to go` : ''}
</span>
</div>
<div className="row">
<span>Today</span>
<span>{gaming.reason}</span>
</div>
<div className="row">
<span>Gaming budget today</span>
<span>
<span className={gaming.workout_today ? 'pill pill-ok' : 'pill pill-lock'}>
{hours}h
</span>{' '}
<span className="muted">
{gaming.workout_today
? 'earned by a workout logged today'
: 'cut from 8h — no workout logged today'}
</span>
</span>
</div>
<div className="row">
<span>Decision stage</span>
<span className="muted">{lock.stage}</span>
</div>
{lock.sources_degraded && (
<div className="row">
<span>Data sources</span>
<span className="state-warn">degraded — some inputs unreadable</span>
</div>
)}
</section>
)
}

View File

@ -0,0 +1,4 @@
<section class="card" aria-labelledby="why-heading" data-card="why" hidden>
<h2 id="why-heading">Why</h2>
<div data-slot></div>
</section>

View File

@ -1,149 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { WhyTable } from './WhyTable'
import { makeDecisions } from '../test/factories'
describe('WhyTable', () => {
it('renders the decision that hid the 2026-08-29 bug', () => {
render(<WhyTable decisions={makeDecisions()} />)
expect(
screen.getByText('Skipped — weekly minimum already met'),
).toBeInTheDocument()
expect(screen.getByText('5/5')).toBeInTheDocument()
expect(screen.getByText('also: early_bird_window_active')).toBeInTheDocument()
})
it('says how much of the trail is shown', () => {
render(<WhyTable decisions={makeDecisions()} />)
expect(screen.getByText(/Showing 1 of 3000/)).toBeInTheDocument()
})
it('marks a decision that actually locked', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T20:00:00+00:00',
locked: true,
reason: 'enforced',
},
],
})}
/>,
)
expect(screen.getByText('locked')).toBeInTheDocument()
})
it('renders a no-decision run as a dash with no week column', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-29T20:00:05+00:00',
locked: null,
reason: 'mode_makes_no_decision',
mode: '--sync-only',
},
],
})}
/>,
)
expect(screen.getByText('—')).toBeInTheDocument()
expect(
screen.getByText('No decision (status or sync-only run)'),
).toBeInTheDocument()
})
it('omits the also line when there is none', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T20:00:00+00:00',
locked: false,
reason: 'relaxed_day',
also: '',
weekly_count: 2,
},
],
})}
/>,
)
expect(screen.queryByText(/^also:/)).not.toBeInTheDocument()
// weekly_required missing falls back to 0 rather than rendering undefined.
expect(screen.getByText('2/0')).toBeInTheDocument()
})
/**
* A restart loop wrote ~1693 identical records on 2026-08-30 and evicted
* the whole trail. They now collapse into one row, which must not then
* read as a gap in history.
*/
it('shows how many times a collapsed row repeated, and when it last did', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T00:02:00+00:00',
locked: true,
reason: 'enforced',
repeat_count: 1693,
last_timestamp: '2026-08-30T03:05:00+00:00',
local_time: 'Aug 30, 02:02',
local_last_time: 'Aug 30, 05:05',
},
],
})}
/>,
)
expect(screen.getByText('Aug 30, 02:02')).toBeInTheDocument()
expect(screen.getByText(/×1693, last Aug 30, 05:05/)).toBeInTheDocument()
})
it('prefers the server-rendered local time over the browser clock', () => {
// LibreWolf's resistFingerprinting pins JS Date to UTC, which rendered a
// 14:00 CEST decision as "12:00 PM" on 2026-08-30.
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T12:00:05+00:00',
locked: true,
reason: 'enforced',
local_time: 'Aug 30, 14:00',
},
],
})}
/>,
)
expect(screen.getByText('Aug 30, 14:00')).toBeInTheDocument()
})
it('falls back to formatting the ISO stamp when the server sent none', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T12:00:05+00:00',
locked: true,
reason: 'enforced',
repeat_count: 3,
},
],
})}
/>,
)
expect(screen.getByText(/×3, last/)).toBeInTheDocument()
})
it('adds no repeat note to a row that happened once', () => {
render(<WhyTable decisions={makeDecisions()} />)
expect(screen.queryByText(/×/)).toBeNull()
})
})

View File

@ -1,74 +0,0 @@
import { describeReason, formatStamp } from '../format'
import type { DecisionsPayload } from '../types'
interface Props {
readonly decisions: DecisionsPayload
}
/**
* The decision history.
*
* This is the view that would have surfaced the 2026-08-29 bug without anyone
* reading the journal: every half-hourly tick recorded
* `weekly_minimum_met 5/5` while the real total was three.
*/
export function WhyTable({ decisions }: Props) {
return (
<section className="card" aria-labelledby="why-heading">
<h2 id="why-heading">Why</h2>
<p className="muted">
Showing {decisions.returned} of {decisions.total} recorded decisions,
newest first.
</p>
<table>
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Outcome</th>
<th scope="col">Reason</th>
<th scope="col">Week</th>
</tr>
</thead>
<tbody>
{decisions.decisions.map((decision, index) => (
<tr key={`${decision.timestamp}-${String(index)}`}>
<td>
{decision.local_time ?? formatStamp(decision.timestamp)}
{decision.repeat_count !== undefined &&
decision.repeat_count > 1 && (
<div className="muted">
×{decision.repeat_count}, last{' '}
{decision.local_last_time ??
formatStamp(
decision.last_timestamp ?? decision.timestamp,
)}
</div>
)}
</td>
<td className={decision.locked === true ? 'state-lock' : 'muted'}>
{decision.locked === null
? '—'
: decision.locked
? 'locked'
: 'no lock'}
</td>
<td>
{describeReason(decision.reason)}
{decision.also !== undefined && decision.also !== '' && (
<div className="muted">also: {decision.also}</div>
)}
</td>
<td className="muted">
{decision.weekly_count === undefined
? ''
: `${String(decision.weekly_count)}/${String(
decision.weekly_required ?? 0,
)}`}
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}

8
web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
/// <reference types="astro/client" />
declare module '*.astro' {
import type { AstroComponentFactory } from 'astro/runtime/server/index.js'
const component: AstroComponentFactory
export default component
}

View File

@ -1,133 +0,0 @@
/*
* Tokens from ~/src/utils/unified-design-system (DOCS-tokens.md). Values are copied
* rather than imported because that system ships as documentation plus
* per-stack generators, not an npm package -- keep these in step with it rather
* than inventing new ones.
*/
:root {
--ink: #211d1b;
--ink-raised-1: #2b2624;
--ink-raised-2: #38312e;
--line-dark: #463e3a;
--text-on-dark: #eceae9;
--muted-on-dark: #aaa09a;
--accent: #b8862e;
--success: #8a9a3c;
--warning: #e0a63c;
--danger: #e2585f;
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
--radius: 8px;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ink);
color: var(--text-on-dark);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.5;
}
main {
margin: 0 auto;
max-width: 900px;
padding: var(--space-4) var(--space-3);
}
h1 {
font-size: 24px;
letter-spacing: -0.1px;
margin: 0 0 var(--space-4);
}
h2 {
font-size: 20px;
margin: 0 0 var(--space-3);
}
.card {
background: var(--ink-raised-1);
border: 1px solid var(--line-dark);
border-radius: var(--radius);
margin-bottom: var(--space-4);
padding: var(--space-4);
}
.headline {
font-size: 32px;
letter-spacing: -0.2px;
margin: 0 0 var(--space-2);
}
.muted {
color: var(--muted-on-dark);
}
.state-ok { color: var(--success); }
.state-warn { color: var(--warning); }
.state-lock { color: var(--danger); }
.row {
border-top: 1px solid var(--line-dark);
display: flex;
gap: var(--space-3);
justify-content: space-between;
padding: var(--space-2) 0;
}
.row:first-of-type {
border-top: none;
}
table {
border-collapse: collapse;
font-size: 14px;
width: 100%;
}
th {
color: var(--muted-on-dark);
font-weight: 600;
letter-spacing: 0.05em;
text-align: left;
}
th,
td {
border-bottom: 1px solid var(--line-dark);
padding: var(--space-2) var(--space-2) var(--space-2) 0;
vertical-align: top;
}
.pill {
background: var(--ink-raised-2);
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.1em;
padding: var(--space-1) var(--space-2);
white-space: nowrap;
}
.pill-lock { background: var(--danger); color: var(--ink); }
.pill-ok { background: var(--success); color: var(--ink); }
.error {
border: 1px solid var(--danger);
border-radius: var(--radius);
color: var(--danger);
margin-bottom: var(--space-4);
padding: var(--space-3);
}

View File

@ -1,12 +1,22 @@
---
import '@kuhyx/web-ui/tokens.css'
import '../styles/index.css'
interface Props {
title: string
}
const { title } = Astro.props
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Screen Locker — status</title>
<title>{title}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<slot />
</body>
</html>

109
web/src/main.test.ts Normal file
View File

@ -0,0 +1,109 @@
import { screen, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startPage } from './main'
import { makeDecisions, makeHealth, makeStatus } from './test/factories'
import { SHELL } from './test/shell'
function stubEndpoints(failing: readonly string[] = []): void {
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
const name = ['status', 'health', 'decisions'].find((n) =>
url.startsWith(`/api/${n}`),
)
if (name !== undefined && failing.includes(name)) {
return Promise.resolve({ ok: false, status: 503, statusText: 'Unavailable' })
}
const body = url.startsWith('/api/status')
? makeStatus()
: url.startsWith('/api/health')
? makeHealth()
: makeDecisions()
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) })
}),
)
}
beforeEach(() => {
document.body.innerHTML = SHELL
})
afterEach(() => {
vi.unstubAllGlobals()
document.body.replaceChildren()
})
describe('startPage', () => {
it('fills all three cards once the endpoints answer', async () => {
stubEndpoints()
const stop = startPage()
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Now' })).toBeVisible()
})
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Health' })).toBeVisible()
expect(screen.getByRole('heading', { name: 'Why' })).toBeVisible()
})
expect(screen.getByText('No lock')).toBeVisible()
expect(screen.getByText('armed')).toBeVisible()
expect(screen.getByText('Skipped — weekly minimum already met')).toBeVisible()
expect(screen.queryByRole('alert')).toBeNull()
expect(screen.getByText('Reading status…')).not.toBeVisible()
stop()
})
it('requests at most 200 decisions', () => {
stubEndpoints()
const stop = startPage()
expect(fetch).toHaveBeenCalledWith('/api/decisions?limit=200')
stop()
})
it('shows a loading line before the first status arrives', () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => undefined)))
const stop = startPage()
expect(screen.getByText('Reading status…')).toBeVisible()
expect(screen.queryByRole('heading', { name: 'Now' })).toBeNull()
stop()
})
it('surfaces a failing endpoint instead of silently showing nothing', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Unavailable' }),
)
const stop = startPage()
await waitFor(() => {
expect(screen.getByRole('alert')).toBeVisible()
})
expect(screen.getByRole('alert')).toHaveTextContent('API returned 503')
stop()
})
it('keeps the healthy cards and reports the one that failed', async () => {
stubEndpoints(['health'])
const stop = startPage()
await waitFor(() => {
expect(screen.getByRole('alert')).toBeVisible()
})
expect(screen.getByRole('heading', { name: 'Now' })).toBeVisible()
expect(screen.queryByRole('heading', { name: 'Health' })).toBeNull()
stop()
})
it('stops polling when the returned function is called', async () => {
vi.useFakeTimers()
stubEndpoints()
const stop = startPage()
stop()
vi.mocked(fetch).mockClear()
await vi.advanceTimersByTimeAsync(60000)
expect(fetch).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('fails loudly when the page shell is missing a piece', () => {
document.body.replaceChildren()
expect(() => startPage()).toThrow(/\[data-errors\] is missing/)
})
})

54
web/src/main.ts Normal file
View File

@ -0,0 +1,54 @@
import { fetchDecisions, fetchHealth, fetchStatus } from './api'
import { Poller, type PollResult } from './poll'
import { renderErrors, renderHealth, renderNow, renderWhy } from './render'
export const DECISION_LIMIT = 200
function find(doc: Document, selector: string): HTMLElement {
const found = doc.querySelector<HTMLElement>(selector)
if (found === null) throw new Error(`${selector} is missing from the page`)
return found
}
/** Wires the three pollers to the three card shells in `index.astro`. */
export function startPage(doc: Document = document): () => void {
const errorSlot = find(doc, '[data-errors]')
const loading = find(doc, '[data-loading]')
const failures = new Map<string, string>()
function bind<T>(
name: string,
fetcher: () => Promise<T>,
render: (slot: HTMLElement, data: T) => void,
): Poller<T> {
const card = find(doc, `[data-card="${name}"]`)
const slot = find(doc, `[data-card="${name}"] [data-slot]`)
return new Poller(
fetcher,
({ data, error }: PollResult<T>) => {
if (data !== null) {
render(slot, data)
card.hidden = false
}
if (error === null) failures.delete(name)
else failures.set(name, error)
renderErrors(errorSlot, [...failures.values()])
},
undefined,
doc,
)
}
const pollers = [
bind('now', fetchStatus, (slot, status) => {
loading.hidden = true
renderNow(slot, status)
}),
bind('health', fetchHealth, renderHealth),
bind('why', () => fetchDecisions(DECISION_LIMIT), renderWhy),
]
for (const poller of pollers) poller.start()
return () => {
for (const poller of pollers) poller.stop()
}
}

View File

@ -1,12 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
const root = document.getElementById('root')
if (root === null) throw new Error('#root is missing from index.html')
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)

50
web/src/page.test.ts Normal file
View File

@ -0,0 +1,50 @@
// @vitest-environment node
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import { describe, expect, it } from 'vitest'
import Index from './pages/index.astro'
import { SHELL } from './test/shell'
async function html(): Promise<string> {
const container = await AstroContainer.create()
return container.renderToString(Index)
}
describe('index.astro', () => {
it('renders the page shell with a title and a heading', async () => {
const page = await html()
expect(page).toContain('<title>Screen Locker — status</title>')
expect(page).toContain('<h1>Screen locker</h1>')
expect(page).toContain('lang="en"')
})
it('renders the three hidden card shells with their slots', async () => {
const page = await html()
for (const name of ['now', 'health', 'why']) {
expect(page).toMatch(
new RegExp(`<section[^>]*data-card="${name}"[^>]*hidden`),
)
}
expect(page.match(/data-slot/g)).toHaveLength(3)
})
it('renders the loading line and a hidden alert region', async () => {
const page = await html()
expect(page).toContain('Reading status…')
expect(page).toMatch(/<div[^>]*role="alert"[^>]*data-errors[^>]*hidden/)
})
})
/** Tag name plus sorted attribute names, for every tag carrying a data-* hook. */
function hooks(markup: string): string[] {
return (markup.match(/<[a-z0-9]+(?:\s[^>]*)?\sdata-[^>]*>/g) ?? []).map((tag) => {
const name = /^<([a-z0-9]+)/.exec(tag)?.[1] ?? ''
const attrs = [...tag.matchAll(/\s([a-z-]+)(?==|\s|>)/g)].map((m) => m[1])
return `${name} ${attrs.sort().join(' ')}`
})
}
describe('test shell', () => {
it('has the same data-* hooks as the real page', async () => {
expect(hooks(SHELL).sort()).toEqual(hooks(await html()).sort())
})
})

22
web/src/pages/index.astro Normal file
View File

@ -0,0 +1,22 @@
---
import HealthCard from '../components/HealthCard.astro'
import NowCard from '../components/NowCard.astro'
import WhyTable from '../components/WhyTable.astro'
import Base from '../layouts/Base.astro'
---
<Base title="Screen Locker — status">
<main>
<h1>Screen locker</h1>
<div class="error" role="alert" data-errors hidden></div>
<p class="muted" data-loading>Reading status…</p>
<NowCard />
<HealthCard />
<WhyTable />
</main>
<script>
import { startPage } from '../main'
startPage()
</script>
</Base>

175
web/src/poll.test.ts Normal file
View File

@ -0,0 +1,175 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { POLL_MS, Poller, type PollResult } from './poll'
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
function setHidden(hidden: boolean): void {
vi.spyOn(document, 'hidden', 'get').mockReturnValue(hidden)
document.dispatchEvent(new Event('visibilitychange'))
}
function make<T>(fetcher: () => Promise<T>, intervalMs = 1000) {
const updates: PollResult<T>[] = []
const poller = new Poller(fetcher, (r) => updates.push(r), intervalMs)
return { poller, updates }
}
describe('Poller', () => {
it('has a 15 s default interval', () => {
expect(POLL_MS).toBe(15000)
})
it('loads immediately rather than waiting for the first interval', async () => {
const { poller, updates } = make(() => Promise.resolve('value'))
poller.start()
await vi.waitFor(() => {
expect(updates).toEqual([{ data: 'value', error: null }])
})
poller.stop()
})
it('surfaces an Error message', async () => {
const { poller, updates } = make(() => Promise.reject(new Error('boom')))
poller.start()
await vi.waitFor(() => {
expect(updates).toEqual([{ data: null, error: 'boom' }])
})
poller.stop()
})
it('stringifies a non-Error rejection instead of rendering nothing', async () => {
const { poller, updates } = make(vi.fn().mockRejectedValue('plain string'))
poller.start()
await vi.waitFor(() => {
expect(updates).toEqual([{ data: null, error: 'plain string' }])
})
poller.stop()
})
it('keeps the last good value on failure and clears the error on recovery', async () => {
vi.useFakeTimers()
const fetcher = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce('first')
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValue('again')
const { poller, updates } = make(fetcher)
poller.start()
await vi.advanceTimersByTimeAsync(2000)
poller.stop()
expect(updates).toEqual([
{ data: 'first', error: null },
{ data: 'first', error: 'boom' },
{ data: 'again', error: null },
])
})
it('polls again on the interval', async () => {
vi.useFakeTimers()
const fetcher = vi.fn(() => Promise.resolve('value'))
const { poller } = make(fetcher)
poller.start()
expect(fetcher).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(2000)
expect(fetcher).toHaveBeenCalledTimes(3)
poller.stop()
})
it('stops polling and drops an in-flight result after stop()', async () => {
vi.useFakeTimers()
let resolve: (value: string) => void = () => undefined
const fetcher = vi.fn(
() =>
new Promise<string>((r) => {
resolve = r
}),
)
const { poller, updates } = make(fetcher)
poller.start()
poller.stop()
resolve('late')
await vi.advanceTimersByTimeAsync(5000)
expect(updates).toEqual([])
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('drops a rejection that lands after stop()', async () => {
let reject: (reason: Error) => void = () => undefined
const fetcher = vi.fn(
() =>
new Promise<string>((_resolve, rej) => {
reject = rej
}),
)
const { poller, updates } = make(fetcher)
poller.start()
poller.stop()
reject(new Error('late'))
await Promise.resolve()
await Promise.resolve()
expect(updates).toEqual([])
})
it('start() twice does not double the interval', async () => {
vi.useFakeTimers()
const fetcher = vi.fn(() => Promise.resolve('value'))
const { poller } = make(fetcher)
poller.start()
poller.start()
fetcher.mockClear()
await vi.advanceTimersByTimeAsync(1000)
expect(fetcher).toHaveBeenCalledTimes(1)
poller.stop()
})
it('pauses the interval while hidden and reloads on becoming visible', async () => {
vi.useFakeTimers()
const fetcher = vi.fn(() => Promise.resolve('value'))
const { poller } = make(fetcher)
poller.start()
setHidden(true)
fetcher.mockClear()
await vi.advanceTimersByTimeAsync(5000)
expect(fetcher).not.toHaveBeenCalled()
setHidden(false)
expect(fetcher).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(fetcher).toHaveBeenCalledTimes(2)
poller.stop()
})
it('still does the first load when started in a hidden tab', async () => {
vi.useFakeTimers()
vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
const fetcher = vi.fn(() => Promise.resolve('value'))
const { poller, updates } = make(fetcher)
poller.start()
await vi.advanceTimersByTimeAsync(5000)
expect(updates).toEqual([{ data: 'value', error: null }])
expect(fetcher).toHaveBeenCalledTimes(1)
poller.stop()
})
it('ignores visibility changes after stop()', () => {
const fetcher = vi.fn(() => Promise.resolve('value'))
const { poller } = make(fetcher)
poller.start()
poller.stop()
fetcher.mockClear()
setHidden(false)
expect(fetcher).not.toHaveBeenCalled()
})
it('uses the shared document and 15 s interval by default', async () => {
vi.useFakeTimers()
const fetcher = vi.fn(() => Promise.resolve('value'))
const poller = new Poller(fetcher, () => undefined)
poller.start()
await vi.advanceTimersByTimeAsync(POLL_MS)
expect(fetcher).toHaveBeenCalledTimes(2)
poller.stop()
})
})

77
web/src/poll.ts Normal file
View File

@ -0,0 +1,77 @@
export const POLL_MS = 15000
export interface PollResult<T> {
readonly data: T | null
readonly error: string | null
}
/**
* Polls a fetcher, keeping the last good value on failure. The first load
* always happens, even in a hidden tab (a guard there once left automation
* tabs stuck on "loading"); only the interval pauses while hidden, and
* becoming visible reloads at once.
*/
export class Poller<T> {
readonly #fetcher: () => Promise<T>
readonly #onUpdate: (result: PollResult<T>) => void
readonly #intervalMs: number
readonly #doc: Document
#data: T | null = null
#handle: ReturnType<typeof setInterval> | undefined
#generation = 0
constructor(
fetcher: () => Promise<T>,
onUpdate: (result: PollResult<T>) => void,
intervalMs: number = POLL_MS,
doc: Document = document,
) {
this.#fetcher = fetcher
this.#onUpdate = onUpdate
this.#intervalMs = intervalMs
this.#doc = doc
}
start(): void {
this.stop()
this.#doc.addEventListener('visibilitychange', this.#onVisibility)
this.#load()
if (!this.#doc.hidden) this.#schedule()
}
stop(): void {
this.#generation += 1
clearInterval(this.#handle)
this.#doc.removeEventListener('visibilitychange', this.#onVisibility)
}
readonly #onVisibility = (): void => {
clearInterval(this.#handle)
if (this.#doc.hidden) return
this.#load()
this.#schedule()
}
#schedule(): void {
this.#handle = setInterval(() => {
this.#load()
}, this.#intervalMs)
}
#load(): void {
const generation = this.#generation
this.#fetcher()
.then((value) => {
if (generation !== this.#generation) return
this.#data = value
this.#onUpdate({ data: value, error: null })
})
.catch((e: unknown) => {
if (generation !== this.#generation) return
this.#onUpdate({
data: this.#data,
error: e instanceof Error ? e.message : String(e),
})
})
}
}

View File

@ -0,0 +1,79 @@
import { within } from '@testing-library/dom'
import { describe, expect, it } from 'vitest'
import { renderErrors, renderHealth } from './render'
import { makeSlot } from './test/dom'
import { makeHealth } from './test/factories'
import type { HealthPayload } from './types'
function show(over: Partial<HealthPayload> = {}) {
const slot = makeSlot()
renderHealth(slot, makeHealth(over))
return within(slot)
}
describe('renderHealth', () => {
it('reports an armed locker', () => {
const view = show()
expect(view.getByText('armed')).toHaveClass('state-ok')
expect(view.getByText('enabled and scheduled')).toBeInTheDocument()
})
it('says an unchecked arming state is NOT a pass', () => {
const view = show({ timers_checked: false, timers: [], armed: false })
expect(view.getByText(/This is not a pass/)).toBeInTheDocument()
})
it('shows the disarm marker when enforcement is switched off', () => {
const view = show({ disarmed: true, armed: false })
expect(view.getByText(/present at/)).toBeInTheDocument()
expect(view.getByText('NOT armed')).toHaveClass('state-lock')
})
it('shows a disarmed timer with its own reason', () => {
const view = show({
armed: false,
timers: [
{
name: 'workout-locker.timer',
enabled: false,
scheduled: false,
armed: false,
describe: 'DISARMED workout-locker.timer: not enabled',
},
],
})
expect(
view.getByText('DISARMED workout-locker.timer: not enabled'),
).toBeInTheDocument()
})
it('renders unknown ages rather than hiding them', () => {
const view = show({ log_age_seconds: null, last_decision_age_seconds: null })
expect(view.getAllByText('unknown ago')).toHaveLength(2)
})
it('formats known ages', () => {
const view = show()
expect(view.getByText('1h ago')).toBeInTheDocument()
expect(view.getByText('2m ago')).toBeInTheDocument()
})
})
describe('renderErrors', () => {
it('shows each distinct message once and unhides the slot', () => {
const slot = makeSlot()
slot.hidden = true
renderErrors(slot, ['boom', 'boom', 'API returned 503 Unavailable'])
expect(slot.hidden).toBe(false)
expect(within(slot).getAllByText('boom')).toHaveLength(1)
expect(slot).toHaveTextContent('API returned 503 Unavailable')
})
it('hides and empties the slot once everything recovers', () => {
const slot = makeSlot()
renderErrors(slot, ['boom'])
renderErrors(slot, [])
expect(slot.hidden).toBe(true)
expect(slot).toBeEmptyDOMElement()
})
})

141
web/src/render-now.test.ts Normal file
View File

@ -0,0 +1,141 @@
import { within } from '@testing-library/dom'
import { describe, expect, it } from 'vitest'
import { renderNow } from './render'
import { makeSlot } from './test/dom'
import { makeStatus } from './test/factories'
import type { StatusPayload } from './types'
function show(status: StatusPayload) {
const slot = makeSlot()
renderNow(slot, status)
return within(slot)
}
function withLock(over: Partial<StatusPayload['snapshot']['lock_explanation']>) {
const base = makeStatus()
return {
...base,
snapshot: {
...base.snapshot,
lock_explanation: { ...base.snapshot.lock_explanation, ...over },
},
}
}
describe('renderNow', () => {
it('shows the cut budget and says why when no workout was logged', () => {
const view = show(makeStatus())
expect(view.getByText('No lock')).toBeInTheDocument()
expect(view.getByText('6h')).toBeInTheDocument()
expect(
view.getByText('cut from 8h — no workout logged today'),
).toBeInTheDocument()
})
it('shows the earned budget once a workout is logged', () => {
const view = show(
makeStatus({
gaming: {
workout_today: true,
credits_today: 1,
reason: '1 counted workout(s) logged today',
},
}),
)
expect(view.getByText('8h')).toBeInTheDocument()
expect(
view.getByText('earned by a workout logged today'),
).toBeInTheDocument()
})
it('says the lock would fire when it would', () => {
const view = show({
...withLock({
fired: true,
stage: 'would_lock',
reason: 'No skip condition applies.',
}),
compliance_state: 'lock',
})
expect(view.getByText('Lock would fire')).toHaveClass('state-lock')
})
it('counts down the remaining workouts', () => {
const view = show(makeStatus())
expect(view.getByText(/3\/5 workouts — 2 to go/)).toBeInTheDocument()
})
it('drops the countdown once the week is met', () => {
const base = makeStatus()
const view = show({
...base,
snapshot: {
...base.snapshot,
week: { ...base.snapshot.week, counted_count: 5, remaining: 0 },
},
})
expect(view.getByText(/5\/5 workouts$/)).toBeInTheDocument()
})
it('flags degraded sources rather than rendering a confident answer', () => {
const view = show(withLock({ sources_degraded: true }))
expect(view.getByText(/degraded/)).toBeInTheDocument()
})
it('renders markup-looking text as text, never as HTML', () => {
const view = show(withLock({ reason: '<img src=x onerror=alert(1)>' }))
expect(view.getByText('<img src=x onerror=alert(1)>')).toBeInTheDocument()
expect(document.querySelector('img')).toBeNull()
})
/**
* "Lock would fire" is a statement about the decision, not about whether
* anything is enforcing it. On 2026-08-30 the page said exactly that while
* the machine had just rebooted and no locker process existed.
*/
const firing = (over: Partial<StatusPayload> = {}): StatusPayload => ({
...withLock({ fired: true }),
...over,
})
it('says when a decided lock is queued behind another holder', () => {
const view = show(
firing({
queue_wait: {
blocked_by: ['wake_alarm'],
elapsed_seconds: 10716,
updated: '2026-08-30T09:01:05+00:00',
},
locker_running: true,
}),
)
expect(view.getByText(/waiting for/)).toBeInTheDocument()
expect(view.getByText(/179 min so far/)).toBeInTheDocument()
expect(view.getByText(/not unlocked/)).toBeInTheDocument()
})
it('says when nothing is enforcing a lock it says would fire', () => {
const view = show(firing({ locker_running: false }))
expect(
view.getByText(/No locker run is in progress right now/),
).toBeInTheDocument()
})
it('stays quiet when a run is up and unblocked', () => {
const view = show(firing({ locker_running: true }))
expect(view.queryByText(/No locker run is in progress/)).toBeNull()
expect(view.queryByText(/waiting for/)).toBeNull()
})
it('says nothing about enforcement when no lock is due', () => {
const view = show(makeStatus({ locker_running: false }))
expect(view.queryByText(/No locker run is in progress/)).toBeNull()
})
it('replaces the previous render instead of appending', () => {
const slot = makeSlot()
renderNow(slot, makeStatus())
renderNow(slot, makeStatus())
expect(within(slot).getAllByText('No lock')).toHaveLength(1)
})
})

156
web/src/render-why.test.ts Normal file
View File

@ -0,0 +1,156 @@
import { within } from '@testing-library/dom'
import { describe, expect, it } from 'vitest'
import { renderWhy } from './render'
import { makeSlot } from './test/dom'
import { makeDecisions } from './test/factories'
import type { DecisionsPayload } from './types'
function show(over: Partial<DecisionsPayload> = {}) {
const slot = makeSlot()
renderWhy(slot, makeDecisions(over))
return within(slot)
}
describe('renderWhy', () => {
it('renders the decision that hid the 2026-08-29 bug', () => {
const view = show()
expect(
view.getByText('Skipped — weekly minimum already met'),
).toBeInTheDocument()
expect(view.getByText('5/5')).toBeInTheDocument()
expect(view.getByText('also: early_bird_window_active')).toBeInTheDocument()
})
it('has the four column headers', () => {
const headers = show().getAllByRole('columnheader')
expect(headers.map((h) => h.textContent)).toEqual([
'When',
'Outcome',
'Reason',
'Week',
])
})
it('says how much of the trail is shown', () => {
expect(show().getByText(/Showing 1 of 3000/)).toBeInTheDocument()
})
it('marks a decision that actually locked', () => {
const view = show({
decisions: [
{
timestamp: '2026-08-30T20:00:00+00:00',
locked: true,
reason: 'enforced',
},
],
})
expect(view.getByText('locked')).toHaveClass('state-lock')
})
it('renders a no-decision run as a dash with no week column', () => {
const view = show({
decisions: [
{
timestamp: '2026-08-29T20:00:05+00:00',
locked: null,
reason: 'mode_makes_no_decision',
mode: '--sync-only',
},
],
})
expect(view.getByText('—')).toBeInTheDocument()
expect(
view.getByText('No decision (status or sync-only run)'),
).toBeInTheDocument()
})
it('omits the also line when there is none', () => {
const view = show({
decisions: [
{
timestamp: '2026-08-30T20:00:00+00:00',
locked: false,
reason: 'relaxed_day',
also: '',
weekly_count: 2,
},
],
})
expect(view.queryByText(/^also:/)).not.toBeInTheDocument()
// weekly_required missing falls back to 0 rather than rendering undefined.
expect(view.getByText('2/0')).toBeInTheDocument()
})
/**
* A restart loop wrote ~1693 identical records on 2026-08-30 and evicted
* the whole trail. They now collapse into one row, which must not then
* read as a gap in history.
*/
it('shows how many times a collapsed row repeated, and when it last did', () => {
const view = show({
decisions: [
{
timestamp: '2026-08-30T00:02:00+00:00',
locked: true,
reason: 'enforced',
repeat_count: 1693,
last_timestamp: '2026-08-30T03:05:00+00:00',
local_time: 'Aug 30, 02:02',
local_last_time: 'Aug 30, 05:05',
},
],
})
expect(view.getByText('Aug 30, 02:02')).toBeInTheDocument()
expect(view.getByText(/×1693, last Aug 30, 05:05/)).toBeInTheDocument()
})
it('prefers the server-rendered local time over the browser clock', () => {
// LibreWolf's resistFingerprinting pins JS Date to UTC, which rendered a
// 14:00 CEST decision as "12:00 PM" on 2026-08-30.
const view = show({
decisions: [
{
timestamp: '2026-08-30T12:00:05+00:00',
locked: true,
reason: 'enforced',
local_time: 'Aug 30, 14:00',
},
],
})
expect(view.getByText('Aug 30, 14:00')).toBeInTheDocument()
})
it('falls back to formatting the ISO stamp when the server sent none', () => {
const view = show({
decisions: [
{
timestamp: '2026-08-30T12:00:05+00:00',
locked: true,
reason: 'enforced',
repeat_count: 3,
},
],
})
expect(view.getByText(/×3, last/)).toBeInTheDocument()
})
it('falls back to the first stamp when a collapsed row has no last one', () => {
const view = show({
decisions: [
{
timestamp: '2026-08-30T12:00:05+00:00',
locked: true,
reason: 'enforced',
repeat_count: 2,
last_timestamp: '2026-08-30T13:00:05+00:00',
},
],
})
expect(view.getByText(/×2, last/)).toBeInTheDocument()
})
it('adds no repeat note to a row that happened once', () => {
expect(show().queryByText(/×/)).toBeNull()
})
})

221
web/src/render.ts Normal file
View File

@ -0,0 +1,221 @@
import { budgetHours, describeReason, formatAge, formatStamp } from './format'
import type {
Decision,
DecisionsPayload,
HealthPayload,
StatusPayload,
} from './types'
type Child = Node | string
/** Build an element; strings become text nodes, never markup. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
classes: string | null,
...children: Child[]
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (classes !== null) node.className = classes
node.append(...children)
return node
}
function row(label: string, ...value: Child[]): HTMLDivElement {
return el('div', 'row', el('span', null, label), el('span', null, ...value))
}
function styledRow(
label: string,
classes: string,
text: string,
): HTMLDivElement {
return row(label, el('span', classes, text))
}
/** The decision as it stands right now, in words rather than slugs. */
export function renderNow(slot: HTMLElement, status: StatusPayload): void {
const {
snapshot,
gaming,
compliance_state: state,
queue_wait: queued,
locker_running: running,
} = status
const { week, lock_explanation: lock } = snapshot
const nodes: Node[] = [
el(
'p',
`headline state-${state}`,
lock.fired ? 'Lock would fire' : 'No lock',
),
el('p', 'muted', lock.reason),
]
if (lock.fired && queued !== null) {
const holders = queued.blocked_by.join(', ')
const minutes = String(Math.round(queued.elapsed_seconds / 60))
nodes.push(
el(
'p',
'state-lock',
`A locker run has already decided to lock and is waiting for ${holders} to release the screen (${minutes} min so far). The screen is held by ${holders}, not unlocked.`,
),
)
}
if (lock.fired && queued === null && running === false) {
nodes.push(
el(
'p',
'state-lock',
'No locker run is in progress right now, so nothing is enforcing this. The next timer tick will start one.',
),
)
}
const counted = `${String(week.counted_count)}/${String(week.minimum)}`
const togo = week.remaining > 0 ? ` — ${String(week.remaining)} to go` : ''
nodes.push(
row('This week', `${counted} workouts${togo}`),
row('Today', gaming.reason),
row(
'Gaming budget today',
el(
'span',
gaming.workout_today ? 'pill pill-ok' : 'pill pill-lock',
`${String(budgetHours(gaming.workout_today))}h`,
),
' ',
el(
'span',
'muted',
gaming.workout_today
? 'earned by a workout logged today'
: 'cut from 8h — no workout logged today',
),
),
styledRow('Decision stage', 'muted', lock.stage),
)
if (lock.sources_degraded) {
nodes.push(
styledRow('Data sources', 'state-warn', 'degraded — some inputs unreadable'),
)
}
slot.replaceChildren(...nodes)
}
/**
* Whether the locker could actually fire.
*
* Every row here is something that once went wrong silently: a timer systemd
* deleted to break an ordering cycle, a disarm marker nobody removed, a log
* that stopped being written. Absences are rendered as values so they cannot
* be mistaken for good news.
*/
export function renderHealth(slot: HTMLElement, health: HealthPayload): void {
const nodes: Node[] = []
if (!health.timers_checked) {
nodes.push(
el(
'p',
'state-warn',
'systemctl unavailable — arming was NOT checked. This is not a pass.',
),
)
}
nodes.push(
styledRow(
'Enforcement',
health.armed ? 'state-ok' : 'state-lock',
health.armed ? 'armed' : 'NOT armed',
),
)
if (health.disarmed) {
nodes.push(
styledRow('Disarm marker', 'state-lock', `present at ${health.disarm_marker}`),
)
}
for (const timer of health.timers) {
nodes.push(
styledRow(
timer.name,
timer.armed ? 'state-ok' : 'state-lock',
timer.armed ? 'enabled and scheduled' : timer.describe,
),
)
}
nodes.push(
styledRow('Workout log written', 'muted', `${formatAge(health.log_age_seconds)} ago`),
styledRow(
'Last decision recorded',
'muted',
`${formatAge(health.last_decision_age_seconds)} ago`,
),
)
slot.replaceChildren(...nodes)
}
function decisionRow(decision: Decision): HTMLTableRowElement {
const when = el('td', null, decision.local_time ?? formatStamp(decision.timestamp))
if (decision.repeat_count !== undefined && decision.repeat_count > 1) {
const last =
decision.local_last_time ??
formatStamp(decision.last_timestamp ?? decision.timestamp)
when.append(el('div', 'muted', `×${String(decision.repeat_count)}, last ${last}`))
}
let outcome = decision.locked ? 'locked' : 'no lock'
if (decision.locked === null) outcome = '—'
const why = el('td', null, describeReason(decision.reason))
if (decision.also !== undefined && decision.also !== '') {
why.append(el('div', 'muted', `also: ${decision.also}`))
}
const week =
decision.weekly_count === undefined
? ''
: `${String(decision.weekly_count)}/${String(decision.weekly_required ?? 0)}`
return el(
'tr',
null,
when,
el('td', decision.locked === true ? 'state-lock' : 'muted', outcome),
why,
el('td', 'muted', week),
)
}
/**
* The decision history.
*
* This is the view that would have surfaced the 2026-08-29 bug without anyone
* reading the journal: every half-hourly tick recorded
* `weekly_minimum_met 5/5` while the real total was three.
*/
export function renderWhy(slot: HTMLElement, decisions: DecisionsPayload): void {
const header = el('tr', null)
for (const title of ['When', 'Outcome', 'Reason', 'Week']) {
const th = el('th', null, title)
th.scope = 'col'
header.append(th)
}
const shown = `Showing ${String(decisions.returned)} of ${String(decisions.total)} recorded decisions, newest first.`
slot.replaceChildren(
el('p', 'muted', shown),
el(
'table',
null,
el('thead', null, header),
el('tbody', null, ...decisions.decisions.map(decisionRow)),
),
)
}
/**
* Shown rather than swallowed: a UI that silently keeps rendering stale data
* is the same failure the locker itself had.
*/
export function renderErrors(
slot: HTMLElement,
messages: readonly string[],
): void {
slot.replaceChildren(
...[...new Set(messages)].map((message) => el('div', null, message)),
)
slot.hidden = messages.length === 0
}

103
web/src/styles/index.css Normal file
View File

@ -0,0 +1,103 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
line-height: 1.5;
}
main {
margin: 0 auto;
max-width: 900px;
padding: var(--space-4) var(--space-3);
}
h1 {
font-size: var(--text-title);
letter-spacing: var(--tracking-title);
margin: 0 0 var(--space-4);
}
h2 {
font-size: var(--text-subtitle);
margin: 0 0 var(--space-3);
}
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
padding: var(--space-4);
}
.headline {
font-size: var(--text-display);
letter-spacing: var(--tracking-display);
margin: 0 0 var(--space-2);
}
.muted {
color: var(--text-muted);
}
.state-ok { color: var(--success); }
.state-warn { color: var(--warning); }
.state-lock { color: var(--danger); }
.row {
border-top: 1px solid var(--border);
display: flex;
gap: var(--space-3);
justify-content: space-between;
padding: var(--space-2) 0;
}
.row:first-of-type {
border-top: none;
}
table {
border-collapse: collapse;
font-size: var(--text-label);
width: 100%;
}
th {
color: var(--text-muted);
font-weight: 600;
letter-spacing: var(--tracking-label);
text-align: left;
}
th,
td {
border-bottom: 1px solid var(--border);
padding: var(--space-2) var(--space-2) var(--space-2) 0;
vertical-align: top;
}
.pill {
background: var(--surface-2);
border-radius: 999px;
font-size: var(--text-caption);
letter-spacing: var(--tracking-caption);
padding: var(--space-1) var(--space-2);
white-space: nowrap;
}
.pill-lock { background: var(--danger); color: var(--on-fill); }
.pill-ok { background: var(--success); color: var(--on-fill); }
.error {
border: 1px solid var(--danger);
border-radius: var(--radius-md);
color: var(--danger);
margin-bottom: var(--space-4);
padding: var(--space-3);
}
[data-slot] {
overflow-x: auto;
}

12
web/src/test/dom.ts Normal file
View File

@ -0,0 +1,12 @@
import { afterEach } from 'vitest'
/** A fresh slot attached to the document, removed after each test. */
export function makeSlot(): HTMLElement {
const slot = document.createElement('div')
document.body.append(slot)
return slot
}
afterEach(() => {
document.body.replaceChildren()
})

View File

@ -1,8 +1 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// React Testing Library does not auto-clean when Vitest globals are disabled.
afterEach(() => {
cleanup()
})

18
web/src/test/shell.ts Normal file
View File

@ -0,0 +1,18 @@
/** Hand-kept copy of the page's `data-*` hooks; page.test.ts fails if it drifts. */
export const SHELL = `
<main>
<div class="error" role="alert" data-errors hidden></div>
<p class="muted" data-loading>Reading status…</p>
<section class="card" aria-labelledby="now-heading" data-card="now" hidden>
<h2 id="now-heading">Now</h2>
<div data-slot></div>
</section>
<section class="card" aria-labelledby="health-heading" data-card="health" hidden>
<h2 id="health-heading">Health</h2>
<div data-slot></div>
</section>
<section class="card" aria-labelledby="why-heading" data-card="why" hidden>
<h2 id="why-heading">Why</h2>
<div data-slot></div>
</section>
</main>`

View File

@ -1,92 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { POLL_MS, usePoll } from './useStatusPoll'
afterEach(() => {
vi.useRealTimers()
})
describe('usePoll', () => {
it('loads immediately rather than waiting for the first interval', async () => {
const fetcher = vi.fn().mockResolvedValue('value')
const { result } = renderHook(() => usePoll(fetcher))
await waitFor(() => {
expect(result.current.data).toBe('value')
})
expect(result.current.error).toBeNull()
})
it('surfaces an Error message', async () => {
const fetcher = vi.fn().mockRejectedValue(new Error('boom'))
const { result } = renderHook(() => usePoll(fetcher))
await waitFor(() => {
expect(result.current.error).toBe('boom')
})
})
it('stringifies a non-Error rejection instead of rendering nothing', async () => {
const fetcher = vi.fn().mockRejectedValue('plain string')
const { result } = renderHook(() => usePoll(fetcher))
await waitFor(() => {
expect(result.current.error).toBe('plain string')
})
})
it('clears a previous error once a load succeeds again', async () => {
const fetcher = vi
.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValue('ok')
const { result, rerender } = renderHook(
({ interval }: { interval: number }) => usePoll(fetcher, interval),
{ initialProps: { interval: 100000 } },
)
await waitFor(() => {
expect(result.current.error).toBe('boom')
})
rerender({ interval: 200000 })
await waitFor(() => {
expect(result.current.data).toBe('ok')
})
expect(result.current.error).toBeNull()
})
it('polls again on the interval', async () => {
vi.useFakeTimers()
const fetcher = vi.fn().mockResolvedValue('value')
renderHook(() => usePoll(fetcher, 1000))
expect(fetcher).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(2000)
expect(fetcher).toHaveBeenCalledTimes(3)
})
it('stops updating after unmount', async () => {
const fetcher = vi.fn().mockResolvedValue('value')
const { result, unmount } = renderHook(() => usePoll(fetcher))
unmount()
await Promise.resolve()
expect(result.current.data).toBeNull()
})
it('ignores a rejection that lands after unmount', async () => {
// The cancelled guard in the catch path: without it React warns about a
// state update on an unmounted component, and a stale error would stick.
let reject: (reason: Error) => void = () => undefined
const fetcher = vi.fn(
() =>
new Promise<string>((_resolve, rej) => {
reject = rej
}),
)
const { result, unmount } = renderHook(() => usePoll(fetcher))
unmount()
reject(new Error('late failure'))
await Promise.resolve()
expect(result.current.error).toBeNull()
})
it('exposes a sane default interval', () => {
expect(POLL_MS).toBeGreaterThan(0)
})
})

View File

@ -1,49 +0,0 @@
import { useEffect, useState } from 'react'
export const POLL_MS = 15000
interface PollResult<T> {
readonly data: T | null
readonly error: string | null
}
/**
* Poll a fetcher on an interval, keeping the last good value on failure.
*
* Deliberately has no `document.hidden` guard. The enforcer's equivalent hook
* had one and it swallowed the first load, leaving automation-driven tabs stuck
* on the loading message forever; see steam-backlog-enforcer useBudgetPoll.
*/
export function usePoll<T>(
fetcher: () => Promise<T>,
intervalMs: number = POLL_MS,
): PollResult<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const load = () => {
fetcher()
.then((value) => {
if (!cancelled) {
setData(value)
setError(null)
}
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e))
}
})
}
load()
const handle = setInterval(load, intervalMs)
return () => {
cancelled = true
clearInterval(handle)
}
}, [fetcher, intervalMs])
return { data, error }
}

View File

@ -1,27 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["es2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"types": ["vite/client"],
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -1,7 +1,10 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"extends": "astro/tsconfigs/strictest",
"compilerOptions": {
"types": ["node"],
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
},
"include": [".astro/types.d.ts", "src", "vitest.config.ts"],
"exclude": ["dist", "coverage"]
}

View File

@ -1,23 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["es2023"],
"module": "esnext",
"moduleResolution": "bundler",
"types": ["node"],
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}

View File

@ -1,35 +0,0 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
// The Python API (`screen-locker-web`) runs on 127.0.0.1:8770 — 8000 belongs to
// steam-backlog-enforcer's own UI. In dev, Vite serves the UI and proxies
// `/api/*` to it, so there are no CORS concerns. In production the Python
// server serves the built bundle from `web/dist`.
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8770',
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/test/**',
'src/main.tsx',
'src/vite-env.d.ts',
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
})

20
web/vitest.config.ts Normal file
View File

@ -0,0 +1,20 @@
/// <reference types="vitest/config" />
import { getViteConfig } from 'astro/config'
export default getViteConfig({
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/test/**', 'src/env.d.ts'],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
})