mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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:
parent
6cd21296a9
commit
f3bd94f646
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,6 +38,7 @@ screen_locker/early_bird_pending.json
|
|||||||
# Web status UI (web/) build and tooling output.
|
# Web status UI (web/) build and tooling output.
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
|
web/.astro/
|
||||||
web/coverage/
|
web/coverage/
|
||||||
web/node_modules
|
web/node_modules
|
||||||
web/dist
|
web/dist
|
||||||
|
|||||||
@ -324,14 +324,14 @@ repos:
|
|||||||
name: eslint (status web UI)
|
name: eslint (status web UI)
|
||||||
entry: pnpm --dir web run lint
|
entry: pnpm --dir web run lint
|
||||||
language: system
|
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
|
pass_filenames: false
|
||||||
require_serial: true
|
require_serial: true
|
||||||
- id: web-typecheck
|
- id: web-typecheck
|
||||||
name: tsc --build (status web UI)
|
name: astro check + build (status web UI)
|
||||||
entry: pnpm --dir web run build
|
entry: pnpm --dir web run build
|
||||||
language: system
|
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
|
pass_filenames: false
|
||||||
require_serial: true
|
require_serial: true
|
||||||
- id: web-coverage
|
- id: web-coverage
|
||||||
|
|||||||
@ -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
16
web/astro.config.mjs
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,25 +1,21 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js'
|
||||||
|
import astro from 'eslint-plugin-astro'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
// Deliberately stricter than steam-backlog-enforcer/web, which this was copied
|
// Deliberately stricter than steam-backlog-enforcer/web, which this was copied
|
||||||
// from: that one extends only `tseslint.configs.recommended` and so runs no
|
// from: that one extends only `tseslint.configs.recommended` and so runs no
|
||||||
// type-aware rules at all. `strictTypeChecked` needs `projectService`, which is
|
// type-aware rules at all. `strictTypeChecked` needs `projectService`, so the
|
||||||
// why tsconfig.test.json exists — test files are outside tsconfig.app.json's
|
// tests live inside tsconfig.json's `src` include and are lintable.
|
||||||
// program and would otherwise be unlintable.
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist', 'coverage']),
|
globalIgnores(['dist', 'coverage', '.astro']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.ts'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.strictTypeChecked,
|
tseslint.configs.strictTypeChecked,
|
||||||
tseslint.configs.stylisticTypeChecked,
|
tseslint.configs.stylisticTypeChecked,
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
@ -29,4 +25,5 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...astro.configs.recommended,
|
||||||
])
|
])
|
||||||
|
|||||||
@ -5,38 +5,33 @@
|
|||||||
"packageManager": "pnpm@12.16.1",
|
"packageManager": "pnpm@12.16.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "astro dev",
|
||||||
"build": "tsc -b && vite build",
|
"build": "astro check && astro build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc -b --noEmit false --emitDeclarationOnly false",
|
"typecheck": "astro check",
|
||||||
"preview": "vite preview",
|
"preview": "astro preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.3.1",
|
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.5.0&path:/web_ui",
|
||||||
"react-dom": "19.3.1"
|
"astro": "7.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "0.9.12",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@testing-library/dom": "10.4.2",
|
"@testing-library/dom": "10.4.2",
|
||||||
"@testing-library/jest-dom": "7.0.1",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/react": "16.3.3",
|
|
||||||
"@testing-library/user-event": "14.6.8",
|
"@testing-library/user-event": "14.6.8",
|
||||||
"@types/node": "26.6.6",
|
"@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",
|
"@vitest/coverage-v8": "5.0.5",
|
||||||
"eslint": "10.14.0",
|
"eslint": "10.14.0",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-astro": "3.2.1",
|
||||||
"eslint-plugin-react-refresh": "0.5.7",
|
"globals": "17.14.0",
|
||||||
"globals": "17.13.0",
|
|
||||||
"jsdom": "30.1.3",
|
"jsdom": "30.1.3",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.72.1",
|
"typescript-eslint": "8.72.1",
|
||||||
"vite": "8.3.5",
|
|
||||||
"vitest": "5.0.5"
|
"vitest": "5.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2883
web/pnpm-lock.yaml
2883
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
2
web/pnpm-workspace.yaml
Normal file
2
web/pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- globals@17.14.0
|
||||||
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
4
web/src/components/HealthCard.astro
Normal file
4
web/src/components/HealthCard.astro
Normal 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>
|
||||||
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
4
web/src/components/NowCard.astro
Normal file
4
web/src/components/NowCard.astro
Normal 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>
|
||||||
@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
4
web/src/components/WhyTable.astro
Normal file
4
web/src/components/WhyTable.astro
Normal 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>
|
||||||
@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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
8
web/src/env.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -1,12 +1,22 @@
|
|||||||
|
---
|
||||||
|
import '@kuhyx/web-ui/tokens.css'
|
||||||
|
import '../styles/index.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title } = Astro.props
|
||||||
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Screen Locker — status</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<slot />
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
109
web/src/main.test.ts
Normal file
109
web/src/main.test.ts
Normal 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
54
web/src/main.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
50
web/src/page.test.ts
Normal 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
22
web/src/pages/index.astro
Normal 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
175
web/src/poll.test.ts
Normal 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
77
web/src/poll.ts
Normal 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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
79
web/src/render-health.test.ts
Normal file
79
web/src/render-health.test.ts
Normal 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
141
web/src/render-now.test.ts
Normal 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
156
web/src/render-why.test.ts
Normal 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
221
web/src/render.ts
Normal 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
103
web/src/styles/index.css
Normal 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
12
web/src/test/dom.ts
Normal 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()
|
||||||
|
})
|
||||||
@ -1,8 +1 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
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
18
web/src/test/shell.ts
Normal 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>`
|
||||||
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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 }
|
|
||||||
}
|
|
||||||
@ -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"]
|
|
||||||
}
|
|
||||||
@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"extends": "astro/tsconfigs/strictest",
|
||||||
"references": [
|
"compilerOptions": {
|
||||||
{ "path": "./tsconfig.app.json" },
|
"types": ["node"],
|
||||||
{ "path": "./tsconfig.node.json" }
|
"erasableSyntaxOnly": true,
|
||||||
]
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": [".astro/types.d.ts", "src", "vitest.config.ts"],
|
||||||
|
"exclude": ["dist", "coverage"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"]
|
|
||||||
}
|
|
||||||
@ -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"]
|
|
||||||
}
|
|
||||||
@ -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
20
web/vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user