From f3bd94f6468c22fd4707eb0e0e09010104a3b7b4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 20:12:24 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU --- .gitignore | 1 + .pre-commit-config.yaml | 6 +- TODO-astro-migration.md | 60 - web/astro.config.mjs | 16 + web/eslint.config.js | 15 +- web/package.json | 23 +- web/pnpm-lock.yaml | 2883 +++++++++++++++++--- web/pnpm-workspace.yaml | 2 + web/src/App.test.tsx | 53 - web/src/App.tsx | 43 - web/src/components/HealthCard.astro | 4 + web/src/components/HealthCard.test.tsx | 61 - web/src/components/HealthCard.tsx | 56 - web/src/components/NowCard.astro | 4 + web/src/components/NowCard.test.tsx | 146 - web/src/components/NowCard.tsx | 78 - web/src/components/WhyTable.astro | 4 + web/src/components/WhyTable.test.tsx | 149 - web/src/components/WhyTable.tsx | 74 - web/src/env.d.ts | 8 + web/src/index.css | 133 - web/{index.html => src/layouts/Base.astro} | 16 +- web/src/main.test.ts | 109 + web/src/main.ts | 54 + web/src/main.tsx | 12 - web/src/page.test.ts | 50 + web/src/pages/index.astro | 22 + web/src/poll.test.ts | 175 ++ web/src/poll.ts | 77 + web/src/render-health.test.ts | 79 + web/src/render-now.test.ts | 141 + web/src/render-why.test.ts | 156 ++ web/src/render.ts | 221 ++ web/src/styles/index.css | 103 + web/src/test/dom.ts | 12 + web/src/test/setup.ts | 7 - web/src/test/shell.ts | 18 + web/src/useStatusPoll.test.ts | 92 - web/src/useStatusPoll.ts | 49 - web/tsconfig.app.json | 27 - web/tsconfig.json | 13 +- web/tsconfig.node.json | 23 - web/tsconfig.test.json | 8 - web/vite.config.ts | 35 - web/vitest.config.ts | 20 + 45 files changed, 3836 insertions(+), 1502 deletions(-) delete mode 100644 TODO-astro-migration.md create mode 100644 web/astro.config.mjs create mode 100644 web/pnpm-workspace.yaml delete mode 100644 web/src/App.test.tsx delete mode 100644 web/src/App.tsx create mode 100644 web/src/components/HealthCard.astro delete mode 100644 web/src/components/HealthCard.test.tsx delete mode 100644 web/src/components/HealthCard.tsx create mode 100644 web/src/components/NowCard.astro delete mode 100644 web/src/components/NowCard.test.tsx delete mode 100644 web/src/components/NowCard.tsx create mode 100644 web/src/components/WhyTable.astro delete mode 100644 web/src/components/WhyTable.test.tsx delete mode 100644 web/src/components/WhyTable.tsx create mode 100644 web/src/env.d.ts delete mode 100644 web/src/index.css rename web/{index.html => src/layouts/Base.astro} (50%) create mode 100644 web/src/main.test.ts create mode 100644 web/src/main.ts delete mode 100644 web/src/main.tsx create mode 100644 web/src/page.test.ts create mode 100644 web/src/pages/index.astro create mode 100644 web/src/poll.test.ts create mode 100644 web/src/poll.ts create mode 100644 web/src/render-health.test.ts create mode 100644 web/src/render-now.test.ts create mode 100644 web/src/render-why.test.ts create mode 100644 web/src/render.ts create mode 100644 web/src/styles/index.css create mode 100644 web/src/test/dom.ts create mode 100644 web/src/test/shell.ts delete mode 100644 web/src/useStatusPoll.test.ts delete mode 100644 web/src/useStatusPoll.ts delete mode 100644 web/tsconfig.app.json delete mode 100644 web/tsconfig.node.json delete mode 100644 web/tsconfig.test.json delete mode 100644 web/vite.config.ts create mode 100644 web/vitest.config.ts diff --git a/.gitignore b/.gitignore index 6001293..58115f0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ screen_locker/early_bird_pending.json # Web status UI (web/) build and tooling output. web/node_modules/ web/dist/ +web/.astro/ web/coverage/ web/node_modules web/dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f98bb5f..ec77910 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -324,14 +324,14 @@ repos: name: eslint (status web UI) entry: pnpm --dir web run lint language: system - files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$' + files: '^web/(src/.*\.(ts|astro)|eslint\.config\.js|package\.json)$' pass_filenames: false require_serial: true - id: web-typecheck - name: tsc --build (status web UI) + name: astro check + build (status web UI) entry: pnpm --dir web run build language: system - files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$' + files: '^web/(src/.*\.(ts|astro)|tsconfig\.json|astro\.config\.mjs|vitest\.config\.ts)$' pass_filenames: false require_serial: true - id: web-coverage diff --git a/TODO-astro-migration.md b/TODO-astro-migration.md deleted file mode 100644 index ef65afa..0000000 --- a/TODO-astro-migration.md +++ /dev/null @@ -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 ` + diff --git a/web/src/main.test.ts b/web/src/main.test.ts new file mode 100644 index 0000000..3a82e03 --- /dev/null +++ b/web/src/main.test.ts @@ -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/) + }) +}) diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..989f199 --- /dev/null +++ b/web/src/main.ts @@ -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(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() + + function bind( + name: string, + fetcher: () => Promise, + render: (slot: HTMLElement, data: T) => void, + ): Poller { + const card = find(doc, `[data-card="${name}"]`) + const slot = find(doc, `[data-card="${name}"] [data-slot]`) + return new Poller( + fetcher, + ({ data, error }: PollResult) => { + 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() + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx deleted file mode 100644 index 7450d9d..0000000 --- a/web/src/main.tsx +++ /dev/null @@ -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( - - - , -) diff --git a/web/src/page.test.ts b/web/src/page.test.ts new file mode 100644 index 0000000..2429a94 --- /dev/null +++ b/web/src/page.test.ts @@ -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 { + 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('Screen Locker — status') + expect(page).toContain('

Screen locker

') + 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(`]*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(/]*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()) + }) +}) diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro new file mode 100644 index 0000000..d17c901 --- /dev/null +++ b/web/src/pages/index.astro @@ -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' +--- + + +
+

Screen locker

+ +

Reading status…

+ + + +
+ + diff --git a/web/src/poll.test.ts b/web/src/poll.test.ts new file mode 100644 index 0000000..dda3186 --- /dev/null +++ b/web/src/poll.test.ts @@ -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(fetcher: () => Promise, intervalMs = 1000) { + const updates: PollResult[] = [] + 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>() + .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((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((_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() + }) +}) diff --git a/web/src/poll.ts b/web/src/poll.ts new file mode 100644 index 0000000..0cfafac --- /dev/null +++ b/web/src/poll.ts @@ -0,0 +1,77 @@ +export const POLL_MS = 15000 + +export interface PollResult { + 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 { + readonly #fetcher: () => Promise + readonly #onUpdate: (result: PollResult) => void + readonly #intervalMs: number + readonly #doc: Document + #data: T | null = null + #handle: ReturnType | undefined + #generation = 0 + + constructor( + fetcher: () => Promise, + onUpdate: (result: PollResult) => 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), + }) + }) + } +} diff --git a/web/src/render-health.test.ts b/web/src/render-health.test.ts new file mode 100644 index 0000000..a129052 --- /dev/null +++ b/web/src/render-health.test.ts @@ -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 = {}) { + 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() + }) +}) diff --git a/web/src/render-now.test.ts b/web/src/render-now.test.ts new file mode 100644 index 0000000..ce3a90a --- /dev/null +++ b/web/src/render-now.test.ts @@ -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) { + 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: '' })) + expect(view.getByText('')).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 => ({ + ...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) + }) +}) diff --git a/web/src/render-why.test.ts b/web/src/render-why.test.ts new file mode 100644 index 0000000..9b82ebd --- /dev/null +++ b/web/src/render-why.test.ts @@ -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 = {}) { + 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() + }) +}) diff --git a/web/src/render.ts b/web/src/render.ts new file mode 100644 index 0000000..32b9a0a --- /dev/null +++ b/web/src/render.ts @@ -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( + 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 +} diff --git a/web/src/styles/index.css b/web/src/styles/index.css new file mode 100644 index 0000000..3dd774c --- /dev/null +++ b/web/src/styles/index.css @@ -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; +} diff --git a/web/src/test/dom.ts b/web/src/test/dom.ts new file mode 100644 index 0000000..5e7d042 --- /dev/null +++ b/web/src/test/dom.ts @@ -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() +}) diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 8be92f5..a9d0dd3 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1,8 +1 @@ import '@testing-library/jest-dom/vitest' -import { cleanup } from '@testing-library/react' -import { afterEach } from 'vitest' - -// React Testing Library does not auto-clean when Vitest globals are disabled. -afterEach(() => { - cleanup() -}) diff --git a/web/src/test/shell.ts b/web/src/test/shell.ts new file mode 100644 index 0000000..d8a2b74 --- /dev/null +++ b/web/src/test/shell.ts @@ -0,0 +1,18 @@ +/** Hand-kept copy of the page's `data-*` hooks; page.test.ts fails if it drifts. */ +export const SHELL = ` +
+ +

Reading status…

+ + + +
` diff --git a/web/src/useStatusPoll.test.ts b/web/src/useStatusPoll.test.ts deleted file mode 100644 index 1bbfdb0..0000000 --- a/web/src/useStatusPoll.test.ts +++ /dev/null @@ -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((_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) - }) -}) diff --git a/web/src/useStatusPoll.ts b/web/src/useStatusPoll.ts deleted file mode 100644 index 39d0034..0000000 --- a/web/src/useStatusPoll.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useEffect, useState } from 'react' - -export const POLL_MS = 15000 - -interface PollResult { - 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( - fetcher: () => Promise, - intervalMs: number = POLL_MS, -): PollResult { - const [data, setData] = useState(null) - const [error, setError] = useState(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 } -} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json deleted file mode 100644 index 11dd0f1..0000000 --- a/web/tsconfig.app.json +++ /dev/null @@ -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"] -} diff --git a/web/tsconfig.json b/web/tsconfig.json index 1ffef60..937ad81 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,7 +1,10 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "extends": "astro/tsconfigs/strictest", + "compilerOptions": { + "types": ["node"], + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "include": [".astro/types.d.ts", "src", "vitest.config.ts"], + "exclude": ["dist", "coverage"] } diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json deleted file mode 100644 index d4e4600..0000000 --- a/web/tsconfig.node.json +++ /dev/null @@ -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"] -} diff --git a/web/tsconfig.test.json b/web/tsconfig.test.json deleted file mode 100644 index 0ea63b6..0000000 --- a/web/tsconfig.test.json +++ /dev/null @@ -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"] -} diff --git a/web/vite.config.ts b/web/vite.config.ts deleted file mode 100644 index 450da95..0000000 --- a/web/vite.config.ts +++ /dev/null @@ -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, - }, - }, - }, -}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..25f5edf --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,20 @@ +/// +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, + }, + }, + }, +})