diff --git a/docs/api/ui/electron_shell.md b/docs/api/ui/electron_shell.md index 5616f48..1fe61f3 100644 --- a/docs/api/ui/electron_shell.md +++ b/docs/api/ui/electron_shell.md @@ -57,6 +57,12 @@ Behavior: a compatible sidecar backend - blocks until the Electron process exits +The Electron workspace allows pnpm to run install-time build scripts for +`electron` and `electron-winstaller` via `allowBuilds` in +`electron/pnpm-workspace.yaml`. Keep those approvals with the workspace +metadata so CI commands that use `pnpm --dir electron ...` see the same +dependency policy as local installs. + The Electron build also generates separate icon assets under `electron/build/`: - `icon.png` for the app/window icon on all platforms diff --git a/docs/api/ui/labeling.md b/docs/api/ui/labeling.md index 91cb1b3..06c9cf9 100644 --- a/docs/api/ui/labeling.md +++ b/docs/api/ui/labeling.md @@ -18,6 +18,11 @@ For frontend development with hot reload: taskclf ui --dev ``` +The frontend workspace allows pnpm to run `esbuild`'s install-time build +script via `allowBuilds` in `src/taskclf/ui/frontend/pnpm-workspace.yaml` +so CI commands that use `pnpm --dir src/taskclf/ui/frontend ...` see the same +dependency policy as local installs. + For browser-based full-stack development with frontend HMR plus backend auto-reload: diff --git a/electron/launcher_choice.ts b/electron/launcher_choice.ts index ffdddaf..6c7e48c 100644 --- a/electron/launcher_choice.ts +++ b/electron/launcher_choice.ts @@ -2,8 +2,8 @@ import { compareVersions } from "./update_policy"; export interface LauncherReleaseEntry { tag_name: string; - draft?: boolean; - prerelease?: boolean; + draft: boolean | undefined; + prerelease: boolean | undefined; } export function launcherVersionFromTag(tag: string): string | null { diff --git a/electron/main.ts b/electron/main.ts index 683042b..6d527b0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -57,7 +57,9 @@ import { warmPillWindow } from "./shell_warm.js"; function readAppDisplayName(): string { const pkgPath = path.join(__dirname, "..", "package.json"); const raw = fs.readFileSync(pkgPath, "utf-8"); - const pkg = JSON.parse(raw) as { build?: { productName?: string } }; + const pkg = JSON.parse(raw) as { + build: { productName: string | undefined } | undefined; + }; return pkg.build?.productName ?? "taskclf"; } @@ -65,9 +67,9 @@ const APP_DISPLAY_NAME = readAppDisplayName(); type HostCommand = { cmd: string; - mode?: string; - message?: string; - prompt?: { + mode: string | undefined; + message: string | undefined; + prompt: { prev_app: string; new_app: string; block_start: string; @@ -75,7 +77,7 @@ type HostCommand = { duration_min: number; suggested_label: string | null; suggestion_text: string | null; - }; + } | undefined; }; const COMPACT_SIZE = { width: 150, height: 30 }; @@ -293,7 +295,7 @@ function launcherLogFilePath(): string { function launcherLog( message: string, level: "info" | "error" = "info", - options?: { echoToConsole?: boolean }, + options: { echoToConsole: boolean | undefined } | undefined = undefined, ): void { const ts = new Date().toISOString(); const line = `[${ts}] [${level}] ${message}`; @@ -364,7 +366,7 @@ function buildLauncherIssueUrl(title: string, detail: string): string { return url; } -function fatalDialogDetail(message: string, detail?: string): string { +function fatalDialogDetail(message: string, detail: string | undefined = undefined): string { const parts: string[] = [message]; if (detail) { parts.push(`Details:\n${detail}`); @@ -379,7 +381,7 @@ function fatalDialogDetail(message: string, detail?: string): string { async function showFatalLaunchError( title: string, message: string, - detail?: string, + detail: string | undefined = undefined, ): Promise { if (fatalLaunchErrorShown || isQuitting) { return; @@ -805,7 +807,7 @@ function transitionNotificationBody(prompt: NonNullable): async function notificationActionPost( pathName: string, - body?: Record, + body: Record | undefined = undefined, ): Promise<{ ok: boolean; detail: string }> { const response = await sidecarRequest(pathName, { method: "POST", @@ -1094,7 +1096,7 @@ async function waitForShell(url: string, timeoutMs = 30000): Promise { async function sidecarRequest( pathName: string, - init?: RequestInit, + init: RequestInit | undefined = undefined, ): Promise { try { return await fetch(`http://127.0.0.1:${uiPort()}${pathName}`, init); @@ -1170,10 +1172,10 @@ let updateCheckInProgress = false; function payloadResolutionDetails( resolution: PayloadResolution, activeVersion: string | null, - options?: { - selectedVersion?: string | null; - note?: string | null; - }, + options: { + selectedVersion: string | null | undefined; + note: string | null | undefined; + } | undefined = undefined, ): string { const lines = [ `Launcher version: ${resolution.launcherManifest.launcher_version}`, @@ -1213,9 +1215,9 @@ async function applyPayloadResolution( async function applyPayloadResolutionAndRelaunch( resolution: PayloadResolution, heading: string, - options?: { - clearSelectedVersionBeforeRelaunch?: boolean; - }, + options: { + clearSelectedVersionBeforeRelaunch: boolean | undefined; + } | undefined = undefined, ): Promise { await applyPayloadResolution(resolution, heading); if (options?.clearSelectedVersionBeforeRelaunch) { @@ -1225,7 +1227,7 @@ async function applyPayloadResolutionAndRelaunch( app.quit(); } -async function showUpdateCheckFailureDialog(detail?: string): Promise { +async function showUpdateCheckFailureDialog(detail: string | undefined = undefined): Promise { await dialog.showMessageBox({ type: "error", title: "Update Check Failed", @@ -2262,11 +2264,11 @@ function formatProgressLine(event: UpdateProgressEvent): string { } } -type ProgressWindowState = { - heading?: string; - detail?: string; - percent?: number | null; -}; +type ProgressWindowState = Partial<{ + heading: string | undefined; + detail: string | undefined; + percent: number | null | undefined; +}>; function progressWindowPageHtml(heading: string, detail = ""): string { const h = escapeHtmlAttr(heading); diff --git a/electron/node_http.ts b/electron/node_http.ts index 8b53919..7ac56b1 100644 --- a/electron/node_http.ts +++ b/electron/node_http.ts @@ -3,13 +3,13 @@ import https from "node:https"; import { Readable } from "node:stream"; export interface NodeFetchInit extends RequestInit { - maxRedirects?: number; + maxRedirects: number | undefined; } const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); const NULL_BODY_STATUS_CODES = new Set([204, 205, 304]); -function abortError(reason?: unknown): Error { +function abortError(reason: unknown | undefined = undefined): Error { if (reason instanceof Error) { return reason; } @@ -71,7 +71,7 @@ async function nodeFetchRequest( remainingRedirects: number, ): Promise { if (request.signal.aborted) { - throw abortError((request.signal as AbortSignal & { reason?: unknown }).reason); + throw abortError((request.signal as AbortSignal & { reason: unknown | undefined }).reason); } const url = new URL(request.url); @@ -170,7 +170,7 @@ async function nodeFetchRequest( req.on("error", fail); const onAbort = () => { - const error = abortError((request.signal as AbortSignal & { reason?: unknown }).reason); + const error = abortError((request.signal as AbortSignal & { reason: unknown | undefined }).reason); req.destroy(error); fail(error); }; @@ -191,7 +191,7 @@ async function nodeFetchRequest( export async function nodeFetch( input: string | URL | Request, - init?: NodeFetchInit, + init: NodeFetchInit | undefined = undefined, ): Promise { const request = input instanceof Request && init === undefined ? input diff --git a/electron/package.json b/electron/package.json index e81b323..ca14bbf 100644 --- a/electron/package.json +++ b/electron/package.json @@ -69,10 +69,5 @@ }, "dependencies": { "adm-zip": "0.5.16" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "electron" - ] } } diff --git a/electron/pnpm-workspace.yaml b/electron/pnpm-workspace.yaml new file mode 100644 index 0000000..e7776fb --- /dev/null +++ b/electron/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + electron: true + electron-winstaller: true diff --git a/electron/port_conflict.ts b/electron/port_conflict.ts index 733c30b..0b18d56 100644 --- a/electron/port_conflict.ts +++ b/electron/port_conflict.ts @@ -24,13 +24,13 @@ export type SpawnSyncResult = { export type SpawnSyncFn = ( command: string, args: readonly string[], - options?: SpawnSyncOptionsWithStringEncoding, + options: SpawnSyncOptionsWithStringEncoding | undefined, ) => SpawnSyncResult; export function defaultSpawnSync( command: string, args: readonly string[], - options?: SpawnSyncOptionsWithStringEncoding, + options: SpawnSyncOptionsWithStringEncoding | undefined = undefined, ): SpawnSyncResult { const r = spawnSync(command, args as string[], { ...options, @@ -111,13 +111,13 @@ function getListeningPidUnix( platform: NodeJS.Platform, spawnSyncFn: SpawnSyncFn, ): number | null { - const lsof = spawnSyncFn("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]); + const lsof = spawnSyncFn("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], undefined); const pidFromLsof = parseFirstPidFromLsofT(lsof.stdout); if (pidFromLsof !== null) { return pidFromLsof; } if (platform === "linux") { - const ss = spawnSyncFn("ss", ["-lntp", `sport = :${port}`]); + const ss = spawnSyncFn("ss", ["-lntp", `sport = :${port}`], undefined); if (ss.status === 0 && ss.stdout) { return parsePidFromSsOutput(ss.stdout); } @@ -128,7 +128,7 @@ function getListeningPidUnix( function getListeningPidWindows(port: number, spawnSyncFn: SpawnSyncFn): number | null { const script = `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess)`; - const ps = spawnSyncFn("powershell.exe", ["-NoProfile", "-Command", script]); + const ps = spawnSyncFn("powershell.exe", ["-NoProfile", "-Command", script], undefined); if (ps.status !== 0 && ps.status !== null) { return null; } @@ -148,14 +148,14 @@ function getProcessCommandLine( if (platform === "win32") { const script = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`; - const ps = spawnSyncFn("powershell.exe", ["-NoProfile", "-Command", script]); + const ps = spawnSyncFn("powershell.exe", ["-NoProfile", "-Command", script], undefined); if (ps.status === 0 && ps.stdout.trim().length > 0) { return ps.stdout.trim(); } return ""; } const field = platform === "linux" ? "args=" : "command="; - const out = spawnSyncFn("ps", ["-p", String(pid), "-ww", "-o", field]); + const out = spawnSyncFn("ps", ["-p", String(pid), "-ww", "-o", field], undefined); if (out.status === 0) { return out.stdout.trim(); } @@ -207,9 +207,9 @@ export async function killPidAndWaitForPortFree( timeoutMs = DEFAULT_KILL_WAIT_MS, ): Promise { if (platform === "win32") { - spawnSyncFn("taskkill.exe", ["/PID", String(pid), "/T"]); + spawnSyncFn("taskkill.exe", ["/PID", String(pid), "/T"], undefined); } else { - spawnSyncFn("kill", ["-TERM", String(pid)]); + spawnSyncFn("kill", ["-TERM", String(pid)], undefined); } const deadline = Date.now() + timeoutMs; @@ -221,9 +221,9 @@ export async function killPidAndWaitForPortFree( } if (platform === "win32") { - spawnSyncFn("taskkill.exe", ["/PID", String(pid), "/T", "/F"]); + spawnSyncFn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], undefined); } else { - spawnSyncFn("kill", ["-KILL", String(pid)]); + spawnSyncFn("kill", ["-KILL", String(pid)], undefined); } const hardDeadline = Date.now() + 3000; diff --git a/electron/update_policy.ts b/electron/update_policy.ts index 8321730..bddf441 100644 --- a/electron/update_policy.ts +++ b/electron/update_policy.ts @@ -65,7 +65,7 @@ export function selectLatestCompatiblePayloadVersion( export function manifestUrlForLauncherVersion( version: string, - overrideUrl?: string, + overrideUrl: string | undefined, ): string { if (overrideUrl && overrideUrl.length > 0) { return overrideUrl; diff --git a/electron/updater.ts b/electron/updater.ts index a319bf0..229bf5c 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -25,8 +25,8 @@ export interface PayloadPlatformData { } export interface PayloadManifest { - kind?: "payload"; - schema_version?: number; + kind: "payload" | undefined; + schema_version: number | undefined; version: string; /** Keys are LLVM-style target triples, e.g. x86_64-unknown-linux-gnu */ platforms: Record; @@ -35,16 +35,16 @@ export interface PayloadManifest { export type Manifest = PayloadManifest; export interface LauncherManifest { - kind?: "launcher"; - schema_version?: number; - version?: string; + kind: "launcher" | undefined; + schema_version: number | undefined; + version: string | undefined; launcher_version: string; payload_index_url: string; - default_payload_selection?: { + default_payload_selection: { strategy: "latest-compatible"; - }; + } | undefined; compatible_payloads: CompatiblePayloadRange; - platforms?: Record; + platforms: Record | undefined; } export interface LauncherPlatformData { @@ -59,7 +59,7 @@ export interface GitHubReleaseAsset { } export interface GitHubRelease extends LauncherReleaseEntry { - assets?: GitHubReleaseAsset[]; + assets: GitHubReleaseAsset[] | undefined; } export interface LauncherResolution { @@ -77,9 +77,9 @@ export interface PayloadIndexEntry { } export interface PayloadIndex { - kind?: "payload-index"; - schema_version?: number; - generated_at?: string; + kind: "payload-index" | undefined; + schema_version: number | undefined; + generated_at: string | undefined; payloads: PayloadIndexEntry[]; } @@ -101,22 +101,22 @@ export type UpdatePhase = "download" | "verify" | "extract"; export interface UpdateProgressEvent { phase: UpdatePhase; /** Bytes received so far during download */ - receivedBytes?: number; + receivedBytes: number | undefined; /** Total bytes when Content-Length is present */ - totalBytes?: number | null; + totalBytes: number | null | undefined; /** 0–100 when known; null if total size is unknown */ - percent?: number | null; + percent: number | null | undefined; } export interface DownloadAndApplyOptions { - onProgress?: (event: UpdateProgressEvent) => void | Promise; + onProgress: ((event: UpdateProgressEvent) => void | Promise) | undefined; } export interface CheckForUpdateOptions { /** Abort manifest fetch after this many milliseconds; <= 0 disables the timeout. */ - timeoutMs?: number; - preferredVersion?: string; - ignoreSelectedVersion?: boolean; + timeoutMs: number | undefined; + preferredVersion: string | undefined; + ignoreSelectedVersion: boolean | undefined; } const GITHUB_LAUNCHER_RELEASES_API_URL = "https://api.github.com/repos/fruitiecutiepie/taskclf/releases?per_page=100"; @@ -146,7 +146,7 @@ function describeFetchError(error: unknown): string { return String(error); } - const cause = (error as Error & { cause?: unknown }).cause; + const cause = (error as Error & { cause: unknown | undefined }).cause; if (cause === undefined || cause === null) { return error.message; } @@ -161,11 +161,12 @@ function describeFetchError(error: unknown): string { async function updaterFetch( input: string, purpose: string, - init?: NodeFetchInit, + init: NodeFetchInit | undefined = undefined, ): Promise { try { return await nodeFetch(input, { cache: "no-store", + maxRedirects: init?.maxRedirects ?? undefined, ...init, }); } catch (error) { @@ -321,8 +322,8 @@ export let lastLauncherCheckFailure: string | null = null; async function fetchWithTimeout( input: string, purpose: string, - timeoutMs?: number, - init?: NodeFetchInit, + timeoutMs: number | undefined = undefined, + init: NodeFetchInit | undefined = undefined, ): Promise { if (timeoutMs === undefined || timeoutMs <= 0) { return updaterFetch(input, purpose, init); @@ -334,7 +335,11 @@ async function fetchWithTimeout( }, timeoutMs); try { - return await updaterFetch(input, purpose, { ...init, signal: controller.signal }); + return await updaterFetch(input, purpose, { + maxRedirects: init?.maxRedirects ?? undefined, + ...init, + signal: controller.signal, + }); } finally { clearTimeout(timer); } @@ -343,8 +348,8 @@ async function fetchWithTimeout( async function fetchJsonWithTimeout( input: string, purpose: string, - timeoutMs?: number, - init?: NodeFetchInit, + timeoutMs: number | undefined = undefined, + init: NodeFetchInit | undefined = undefined, ): Promise { const res = await fetchWithTimeout(input, purpose, timeoutMs, init); if (!res.ok) { @@ -389,7 +394,7 @@ function launcherManifestUrlFromRelease( if (assetUrl) { return assetUrl; } - return manifestUrlForLauncherVersion(version); + return manifestUrlForLauncherVersion(version, undefined); } function validateLauncherPlatformData( @@ -419,8 +424,8 @@ function findPayloadIndexEntry(payloadIndex: PayloadIndex, version: string): Pay function resolveDesiredPayloadVersion( launcherManifest: LauncherManifest, payloadIndex: PayloadIndex, - preferredVersion?: string, - ignoreSelectedVersion?: boolean, + preferredVersion: string | undefined = undefined, + ignoreSelectedVersion: boolean | undefined = undefined, ): { defaultVersion: string; version: string; @@ -478,7 +483,7 @@ function resolveDesiredPayloadVersion( } export async function resolvePayloadRelease( - options?: CheckForUpdateOptions, + options: Partial | undefined = undefined, ): Promise { const launcherManifestUrl = manifestUrlForLauncherVersion( app.getVersion(), @@ -554,7 +559,7 @@ export async function resolvePayloadRelease( } export async function resolveLauncherRelease( - options?: CheckForUpdateOptions, + options: Partial | undefined = undefined, ): Promise { const timeoutMs = options?.timeoutMs; lastLauncherCheckFailure = null; @@ -564,7 +569,7 @@ export async function resolveLauncherRelease( GITHUB_LAUNCHER_RELEASES_API_URL, "launcher releases", timeoutMs, - { headers: githubApiHeaders() }, + { headers: githubApiHeaders(), maxRedirects: undefined }, ); const latestTag = selectLatestLauncherReleaseTag(releases); if (latestTag === null) { @@ -583,7 +588,7 @@ export async function resolveLauncherRelease( launcherManifestUrlFromRelease(matchingRelease, latestVersion), `launcher manifest for ${latestTag}`, timeoutMs, - { headers: githubApiHeaders() }, + { headers: githubApiHeaders(), maxRedirects: undefined }, ); const effectiveLatestVersion = latestManifest.version ?? latestManifest.launcher_version ?? latestVersion; const currentVersion = app.getVersion(); @@ -612,7 +617,7 @@ export async function resolveLauncherRelease( } export async function checkForUpdate( - options?: CheckForUpdateOptions, + options: Partial | undefined = undefined, ): Promise { const resolution = await resolvePayloadRelease(options); if (resolution === null) { @@ -701,7 +706,7 @@ async function streamPayloadToFile( export async function downloadAndApplyUpdate( manifest: PayloadManifest, - options?: DownloadAndApplyOptions, + options: DownloadAndApplyOptions | undefined = undefined, ): Promise { const onProgress = options?.onProgress; try { @@ -735,11 +740,21 @@ export async function downloadAndApplyUpdate( // Verify Hash (already verified while streaming; emit phase for UI) console.log(`[updater] Verifying hash...`); - await emitProgress(onProgress, { phase: "verify", percent: 100 }); + await emitProgress(onProgress, { + phase: "verify", + receivedBytes: undefined, + totalBytes: undefined, + percent: 100, + }); // Extract console.log(`[updater] Extracting payload...`); - await emitProgress(onProgress, { phase: "extract", percent: null }); + await emitProgress(onProgress, { + phase: "extract", + receivedBytes: undefined, + totalBytes: undefined, + percent: null, + }); const zip = new AdmZip(zipPath); zip.extractAllTo(payloadDir, true); @@ -766,7 +781,7 @@ export async function downloadAndApplyUpdate( export async function downloadLauncherInstaller( resolution: LauncherResolution, - options?: DownloadAndApplyOptions, + options: DownloadAndApplyOptions | undefined = undefined, ): Promise { if (!resolution.updateAvailable) { throw new Error(`Launcher v${resolution.currentVersion} is already current`); diff --git a/src/taskclf/ui/frontend/biome.json b/src/taskclf/ui/frontend/biome.json index fae34b7..29dd07b 100644 --- a/src/taskclf/ui/frontend/biome.json +++ b/src/taskclf/ui/frontend/biome.json @@ -27,7 +27,7 @@ "noExplicitAny": "warn" }, "complexity": { - "useOptionalChain": "error" + "useOptionalChain": "off" }, "nursery": { "noJsxPropsBind": "off" diff --git a/src/taskclf/ui/frontend/pnpm-workspace.yaml b/src/taskclf/ui/frontend/pnpm-workspace.yaml index efc037a..5ed0b5a 100644 --- a/src/taskclf/ui/frontend/pnpm-workspace.yaml +++ b/src/taskclf/ui/frontend/pnpm-workspace.yaml @@ -1,2 +1,2 @@ -onlyBuiltDependencies: - - esbuild +allowBuilds: + esbuild: true diff --git a/src/taskclf/ui/frontend/src/App.test.tsx b/src/taskclf/ui/frontend/src/App.test.tsx index 1e25d02..0d71fc2 100644 --- a/src/taskclf/ui/frontend/src/App.test.tsx +++ b/src/taskclf/ui/frontend/src/App.test.tsx @@ -169,8 +169,8 @@ describe("App drag regions", () => { type: "status", state: "idle", current_app: "unknown", - current_app_since: null, - candidate_app: null, + current_app_since: undefined, + candidate_app: undefined, candidate_duration_s: 0, transition_threshold_s: 0, poll_seconds: 0, @@ -183,7 +183,7 @@ describe("App drag regions", () => { state: "checking", summary_available: false, endpoint: "http://localhost:5600", - source_id: null, + source_id: undefined, last_sample_count: 0, last_sample_breakdown: {}, setup_title: "Activity source unavailable", @@ -197,30 +197,30 @@ describe("App drag regions", () => { help_url: "https://activitywatch.net/", }, aw_connected: false, - aw_bucket_id: null, + aw_bucket_id: undefined, aw_host: "http://localhost:5600", last_event_count: 0, last_app_counts: {}, }), - latest_prediction: () => null, + latest_prediction: () => undefined, latest_tray_state: () => ({ type: "tray_state", model_loaded: false, - model_dir: null, - model_schema_hash: null, - suggested_label: null, - suggested_confidence: null, + model_dir: undefined, + model_schema_hash: undefined, + suggested_label: undefined, + suggested_confidence: undefined, transition_count: 0, - last_transition: null, + last_transition: undefined, labels_saved_count: 0, data_dir: "~/.taskclf", ui_port: 0, dev_mode: false, paused: false, }), - active_suggestion: () => null, - latest_prompt: () => null, - live_status: () => null, + active_suggestion: () => undefined, + latest_prompt: () => undefined, + live_status: () => undefined, label_grid_requested: () => 0, connection_status: () => "connected", ws_stats: () => ({ @@ -229,19 +229,19 @@ describe("App drag regions", () => { prediction_count: 0, tray_state_count: 0, suggestion_count: 0, - last_message_at: null, + last_message_at: undefined, reconnect_count: 0, - connected_since: null, + connected_since: undefined, }), train_state: () => ({ - job_id: null, + job_id: undefined, status: "idle", - step: null, - progress_pct: null, - message: null, - error: null, - metrics: null, - model_dir: null, + step: undefined, + progress_pct: undefined, + message: undefined, + error: undefined, + metrics: undefined, + model_dir: undefined, }), suggestion_dismiss: vi.fn(), }), @@ -291,8 +291,8 @@ describe("App drag regions", () => { type: "status", state: "idle", current_app: "unknown", - current_app_since: null, - candidate_app: null, + current_app_since: undefined, + candidate_app: undefined, candidate_duration_s: 0, transition_threshold_s: 0, poll_seconds: 0, @@ -305,7 +305,7 @@ describe("App drag regions", () => { state: "checking", summary_available: false, endpoint: "http://localhost:5600", - source_id: null, + source_id: undefined, last_sample_count: 0, last_sample_breakdown: {}, setup_title: "Activity source unavailable", @@ -319,30 +319,30 @@ describe("App drag regions", () => { help_url: "https://activitywatch.net/", }, aw_connected: false, - aw_bucket_id: null, + aw_bucket_id: undefined, aw_host: "http://localhost:5600", last_event_count: 0, last_app_counts: {}, }), - latest_prediction: () => null, + latest_prediction: () => undefined, latest_tray_state: () => ({ type: "tray_state", model_loaded: false, - model_dir: null, - model_schema_hash: null, - suggested_label: null, - suggested_confidence: null, + model_dir: undefined, + model_schema_hash: undefined, + suggested_label: undefined, + suggested_confidence: undefined, transition_count: 0, - last_transition: null, + last_transition: undefined, labels_saved_count: 0, data_dir: "~/.taskclf", ui_port: 0, dev_mode: false, paused: false, }), - active_suggestion: () => null, - latest_prompt: () => null, - live_status: () => null, + active_suggestion: () => undefined, + latest_prompt: () => undefined, + live_status: () => undefined, label_grid_requested: () => 0, connection_status: () => "connected", ws_stats: () => ({ @@ -351,19 +351,19 @@ describe("App drag regions", () => { prediction_count: 0, tray_state_count: 0, suggestion_count: 0, - last_message_at: null, + last_message_at: undefined, reconnect_count: 0, - connected_since: null, + connected_since: undefined, }), train_state: () => ({ - job_id: null, + job_id: undefined, status: "idle", - step: null, - progress_pct: null, - message: null, - error: null, - metrics: null, - model_dir: null, + step: undefined, + progress_pct: undefined, + message: undefined, + error: undefined, + metrics: undefined, + model_dir: undefined, }), suggestion_dismiss: vi.fn(), }), @@ -426,8 +426,8 @@ describe("App drag regions", () => { type: "status", state: "idle", current_app: "unknown", - current_app_since: null, - candidate_app: null, + current_app_since: undefined, + candidate_app: undefined, candidate_duration_s: 0, transition_threshold_s: 0, poll_seconds: 0, @@ -440,7 +440,7 @@ describe("App drag regions", () => { state: "checking", summary_available: false, endpoint: "http://localhost:5600", - source_id: null, + source_id: undefined, last_sample_count: 0, last_sample_breakdown: {}, setup_title: "Activity source unavailable", @@ -454,30 +454,30 @@ describe("App drag regions", () => { help_url: "https://activitywatch.net/", }, aw_connected: false, - aw_bucket_id: null, + aw_bucket_id: undefined, aw_host: "http://localhost:5600", last_event_count: 0, last_app_counts: {}, }), - latest_prediction: () => null, + latest_prediction: () => undefined, latest_tray_state: () => ({ type: "tray_state", model_loaded: false, - model_dir: null, - model_schema_hash: null, - suggested_label: null, - suggested_confidence: null, + model_dir: undefined, + model_schema_hash: undefined, + suggested_label: undefined, + suggested_confidence: undefined, transition_count: 0, - last_transition: null, + last_transition: undefined, labels_saved_count: 0, data_dir: "~/.taskclf", ui_port: 0, dev_mode: false, paused: false, }), - active_suggestion: () => null, + active_suggestion: () => undefined, latest_prompt: () => prompt_store, - live_status: () => null, + live_status: () => undefined, label_grid_requested: () => 0, connection_status: () => "connected", ws_stats: () => ({ @@ -486,19 +486,19 @@ describe("App drag regions", () => { prediction_count: 0, tray_state_count: 0, suggestion_count: 0, - last_message_at: null, + last_message_at: undefined, reconnect_count: 0, - connected_since: null, + connected_since: undefined, }), train_state: () => ({ - job_id: null, + job_id: undefined, status: "idle", - step: null, - progress_pct: null, - message: null, - error: null, - metrics: null, - model_dir: null, + step: undefined, + progress_pct: undefined, + message: undefined, + error: undefined, + metrics: undefined, + model_dir: undefined, }), suggestion_dismiss: vi.fn(), }), @@ -556,8 +556,8 @@ describe("App drag regions", () => { type: "status", state: "idle", current_app: "unknown", - current_app_since: null, - candidate_app: null, + current_app_since: undefined, + candidate_app: undefined, candidate_duration_s: 0, transition_threshold_s: 0, poll_seconds: 0, @@ -570,7 +570,7 @@ describe("App drag regions", () => { state: "checking", summary_available: false, endpoint: "http://localhost:5600", - source_id: null, + source_id: undefined, last_sample_count: 0, last_sample_breakdown: {}, setup_title: "Activity source unavailable", @@ -584,30 +584,30 @@ describe("App drag regions", () => { help_url: "https://activitywatch.net/", }, aw_connected: false, - aw_bucket_id: null, + aw_bucket_id: undefined, aw_host: "http://localhost:5600", last_event_count: 0, last_app_counts: {}, }), - latest_prediction: () => null, + latest_prediction: () => undefined, latest_tray_state: () => ({ type: "tray_state", model_loaded: false, - model_dir: null, - model_schema_hash: null, - suggested_label: null, - suggested_confidence: null, + model_dir: undefined, + model_schema_hash: undefined, + suggested_label: undefined, + suggested_confidence: undefined, transition_count: 0, - last_transition: null, + last_transition: undefined, labels_saved_count: 0, data_dir: "~/.taskclf", ui_port: 0, dev_mode: false, paused: false, }), - active_suggestion: () => null, + active_suggestion: () => undefined, latest_prompt: () => prompt, - live_status: () => null, + live_status: () => undefined, label_grid_requested: () => 0, connection_status: () => "connected", ws_stats: () => ({ @@ -616,19 +616,19 @@ describe("App drag regions", () => { prediction_count: 0, tray_state_count: 0, suggestion_count: 0, - last_message_at: null, + last_message_at: undefined, reconnect_count: 0, - connected_since: null, + connected_since: undefined, }), train_state: () => ({ - job_id: null, + job_id: undefined, status: "idle", - step: null, - progress_pct: null, - message: null, - error: null, - metrics: null, - model_dir: null, + step: undefined, + progress_pct: undefined, + message: undefined, + error: undefined, + metrics: undefined, + model_dir: undefined, }), suggestion_dismiss: vi.fn(), }), diff --git a/src/taskclf/ui/frontend/src/App.tsx b/src/taskclf/ui/frontend/src/App.tsx index 8d2114a..7f02243 100644 --- a/src/taskclf/ui/frontend/src/App.tsx +++ b/src/taskclf/ui/frontend/src/App.tsx @@ -73,8 +73,8 @@ const App: Component = () => { const [panel_hovered, set_panel_hovered] = createSignal(false); const label_visible = () => label_pinned() || badge_hovered() || label_hovered(); const panel_visible = () => panel_pinned() || dot_hovered() || panel_hovered(); - let label_hide_timer: ReturnType | null = null; - let panel_hide_timer: ReturnType | null = null; + let label_hide_timer: ReturnType | undefined; + let panel_hide_timer: ReturnType | undefined; const open_label_grid = () => { if (browser_compact) { @@ -85,16 +85,16 @@ const App: Component = () => { }; const label_hide_cancel = () => { - if (label_hide_timer !== null) { + if (label_hide_timer !== undefined) { clearTimeout(label_hide_timer); - label_hide_timer = null; + label_hide_timer = undefined; } }; const panel_hide_cancel = () => { - if (panel_hide_timer !== null) { + if (panel_hide_timer !== undefined) { clearTimeout(panel_hide_timer); - panel_hide_timer = null; + panel_hide_timer = undefined; } }; @@ -106,7 +106,7 @@ const App: Component = () => { label_hide_timer = setTimeout(() => { set_badge_hovered(false); set_label_hovered(false); - label_hide_timer = null; + label_hide_timer = undefined; }, CHILD_HIDE_DELAY_MS); }; @@ -118,7 +118,7 @@ const App: Component = () => { panel_hide_timer = setTimeout(() => { set_dot_hovered(false); set_panel_hovered(false); - panel_hide_timer = null; + panel_hide_timer = undefined; }, CHILD_HIDE_DELAY_MS); }; @@ -350,6 +350,7 @@ const App: Component = () => { }} > { label_hide_cancel(); set_label_pinned(false); diff --git a/src/taskclf/ui/frontend/src/components/ActivitySourceSetupCallout.tsx b/src/taskclf/ui/frontend/src/components/ActivitySourceSetupCallout.tsx index 50478ca..842ba48 100644 --- a/src/taskclf/ui/frontend/src/components/ActivitySourceSetupCallout.tsx +++ b/src/taskclf/ui/frontend/src/components/ActivitySourceSetupCallout.tsx @@ -3,7 +3,7 @@ import type { ActivityProviderStatus } from "../lib/api"; export const ActivitySourceSetupCallout: Component<{ provider: ActivityProviderStatus; - compact?: boolean; + compact: boolean | undefined; }> = (props) => { const compact = () => props.compact ?? false; diff --git a/src/taskclf/ui/frontend/src/components/ActivitySummary.test.tsx b/src/taskclf/ui/frontend/src/components/ActivitySummary.test.tsx index 472f004..6d9c725 100644 --- a/src/taskclf/ui/frontend/src/components/ActivitySummary.test.tsx +++ b/src/taskclf/ui/frontend/src/components/ActivitySummary.test.tsx @@ -44,9 +44,9 @@ function activity_summary_make( activity_provider: activity_provider_make(activity_provider), recent_apps: [], top_apps: [], - mean_keys_per_min: null, - mean_clicks_per_min: null, - mean_scroll_per_min: null, + mean_keys_per_min: undefined, + mean_clicks_per_min: undefined, + mean_scroll_per_min: undefined, total_buckets: 0, session_count: 0, range_state: "no_data", @@ -64,7 +64,14 @@ describe("ActivitySummary", () => { it("shows a no-data message for empty ranges", async () => { vi.mocked(activity_summary_get).mockResolvedValueOnce(activity_summary_make()); - render(() => ); + render(() => ( + + )); expect( await screen.findByText("No activity data for this window"), @@ -77,7 +84,7 @@ describe("ActivitySummary", () => { activity_provider: activity_provider_make({ state: "setup_required", summary_available: false, - source_id: null, + source_id: undefined, }), range_state: "provider_unavailable", message: @@ -85,7 +92,14 @@ describe("ActivitySummary", () => { }), ); - render(() => ); + render(() => ( + + )); expect(await screen.findByText("Activity source unavailable")).toBeInTheDocument(); expect( @@ -96,7 +110,14 @@ describe("ActivitySummary", () => { it("shows a generic fallback when the summary request fails", async () => { vi.mocked(activity_summary_get).mockRejectedValueOnce(new Error("boom")); - render(() => ); + render(() => ( + + )); await waitFor(() => { expect( diff --git a/src/taskclf/ui/frontend/src/components/ActivitySummary.tsx b/src/taskclf/ui/frontend/src/components/ActivitySummary.tsx index 1bb95f8..ef81f7a 100644 --- a/src/taskclf/ui/frontend/src/components/ActivitySummary.tsx +++ b/src/taskclf/ui/frontend/src/components/ActivitySummary.tsx @@ -45,23 +45,25 @@ const PredictionBadge: Component<{ p: Accessor }> = (props) => ( ); export const ActivitySummary: Component<{ - minutes?: Accessor; - time_range?: Accessor; - prediction?: Accessor; - show_empty?: boolean; + minutes: Accessor | undefined; + time_range: Accessor | undefined; + prediction: Accessor | undefined; + show_empty: boolean | undefined; }> = (props) => { const range = () => props.time_range?.() - ?? (props.minutes ? time_range_minutes(props.minutes()) : null); + ?? (props.minutes ? time_range_minutes(props.minutes()) : undefined); - const [summary, set_summary] = createSignal(null); + const [summary, set_summary] = createSignal( + undefined, + ); const [is_loading, set_is_loading] = createSignal(false); const [request_failed, set_request_failed] = createSignal(false); createEffect(() => { const r = range(); if (!r) { - set_summary(null); + set_summary(undefined); set_is_loading(false); set_request_failed(false); return; @@ -83,7 +85,7 @@ export const ActivitySummary: Component<{ if (cancelled) { return; } - set_summary(null); + set_summary(undefined); set_request_failed(true); set_is_loading(false); }); @@ -94,7 +96,7 @@ export const ActivitySummary: Component<{ }); const pred = () => props.prediction?.(); - const provider = () => summary()?.activity_provider ?? null; + const provider = () => summary()?.activity_provider ?? undefined; const recent_apps = () => (summary()?.recent_apps ?? []).slice(0, 3); const has_recent_apps = () => recent_apps().length > 0; const feature_apps = () => (summary()?.top_apps ?? []).slice(0, 5); @@ -102,7 +104,9 @@ export const ActivitySummary: Component<{ const has_apps = () => has_recent_apps() || has_feature_apps(); const has_stats = () => { const s = summary(); - return s && (s.mean_keys_per_min != null || s.mean_clicks_per_min != null); + return ( + s && (s.mean_keys_per_min !== undefined || s.mean_clicks_per_min !== undefined) + ); }; const has_coverage = () => { const s = summary(); @@ -194,46 +198,20 @@ export const ActivitySummary: Component<{ - <> - -
- - {(entry) => ( - - - {app_name_short(entry.app_id)} - - {entry.buckets}m - - )} - - } - > - + +
+ {(entry) => ( - {app_name_short(entry.app)} + {app_name_short(entry.app_id)} - {entry.events} + {entry.buckets}m )} - -
-
- - -
- - {(v) => keys {v()}/m} - - - {(v) => clicks {v()}/m} - - - {(v) => scroll {v()}/m} - - - - {summary()?.total_buckets}m - 1}> - {" "} - / {summary()?.session_count} sessions - - - -
-
- + + {(entry) => ( + + + {app_name_short(entry.app)} + + {entry.events} + + )} + +
+
+
+ + +
+ + {(v) => keys {v()}/m} + + + {(v) => clicks {v()}/m} + + + {(v) => scroll {v()}/m} + + + + {summary()?.total_buckets}m + 1}> + {" "} + / {summary()?.session_count} sessions + + + +
+
diff --git a/src/taskclf/ui/frontend/src/components/ConnectionDot.tsx b/src/taskclf/ui/frontend/src/components/ConnectionDot.tsx index a04a1a3..236c71a 100644 --- a/src/taskclf/ui/frontend/src/components/ConnectionDot.tsx +++ b/src/taskclf/ui/frontend/src/components/ConnectionDot.tsx @@ -4,10 +4,10 @@ import type { ConnectionStatus } from "../lib/ws"; export const ConnectionDot: Component<{ status: Accessor; - panel_pinned?: Accessor; - on_toggle_panel?: () => void; - on_show_panel?: () => void; - on_hide_panel?: () => void; + panel_pinned: Accessor | undefined; + on_toggle_panel: (() => void) | undefined; + on_show_panel: (() => void) | undefined; + on_hide_panel: (() => void) | undefined; }> = (props) => { const [hovered, set_hovered] = createSignal(false); const color = () => dot_color(props.status()); diff --git a/src/taskclf/ui/frontend/src/components/ErrorBanner.test.tsx b/src/taskclf/ui/frontend/src/components/ErrorBanner.test.tsx index 7bc99a3..165878c 100644 --- a/src/taskclf/ui/frontend/src/components/ErrorBanner.test.tsx +++ b/src/taskclf/ui/frontend/src/components/ErrorBanner.test.tsx @@ -14,7 +14,7 @@ describe("ErrorBanner", () => { }); it("copies the current error text", async () => { - render(() => ); + render(() => ); fireEvent.click(screen.getByRole("button", { name: "Copy error" })); diff --git a/src/taskclf/ui/frontend/src/components/ErrorBanner.tsx b/src/taskclf/ui/frontend/src/components/ErrorBanner.tsx index 0e82081..c70ae86 100644 --- a/src/taskclf/ui/frontend/src/components/ErrorBanner.tsx +++ b/src/taskclf/ui/frontend/src/components/ErrorBanner.tsx @@ -31,15 +31,15 @@ async function error_text_copy(text: string): Promise { export const ErrorBanner: Component<{ message: string; - on_close?: () => void; + on_close: (() => void) | undefined; }> = (props) => { const [copy_state, set_copy_state] = createSignal<"idle" | "copied" | "failed">( "idle", ); - let reset_timer: ReturnType | null = null; + let reset_timer: ReturnType | undefined; onCleanup(() => { - if (reset_timer !== null) { + if (reset_timer !== undefined) { clearTimeout(reset_timer); } }); @@ -52,12 +52,12 @@ export const ErrorBanner: Component<{ frontend_log_error("Failed to copy error message", err); set_copy_state("failed"); } finally { - if (reset_timer !== null) { + if (reset_timer !== undefined) { clearTimeout(reset_timer); } reset_timer = setTimeout(() => { set_copy_state("idle"); - reset_timer = null; + reset_timer = undefined; }, 2000); } } diff --git a/src/taskclf/ui/frontend/src/components/LabelForm.tsx b/src/taskclf/ui/frontend/src/components/LabelForm.tsx index 233ec85..24e7e9e 100644 --- a/src/taskclf/ui/frontend/src/components/LabelForm.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelForm.tsx @@ -28,20 +28,27 @@ export const LabelForm: Component = () => { const [end_ts, set_end_ts] = createSignal(""); const [label, set_label] = createSignal(""); const [confidence, set_confidence] = createSignal(0.8); - const [status, set_status] = createSignal<{ - type: "success" | "error"; - msg: string; - } | null>(null); + const [status, set_status] = createSignal< + | { + type: "success" | "error"; + msg: string; + } + | undefined + >(undefined); async function label_submit(e: Event) { e.preventDefault(); - set_status(null); + set_status(undefined); try { const result = await label_create({ start_ts: start_ts(), end_ts: end_ts(), label: label(), + user_id: undefined, confidence: confidence(), + extend_forward: undefined, + overwrite: undefined, + allow_overlap: undefined, }); set_status({ type: "success", diff --git a/src/taskclf/ui/frontend/src/components/LabelHistory.test.tsx b/src/taskclf/ui/frontend/src/components/LabelHistory.test.tsx index 767a8fe..ee62aa7 100644 --- a/src/taskclf/ui/frontend/src/components/LabelHistory.test.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelHistory.test.tsx @@ -38,7 +38,7 @@ describe("LabelHistory", () => { end_ts: iso_at_local_time(date_str, 10), label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }, @@ -49,7 +49,7 @@ describe("LabelHistory", () => { end_ts: iso_at_local_time(date_str, 10), label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }, @@ -58,7 +58,7 @@ describe("LabelHistory", () => { end_ts: iso_at_local_time(date_str, 11), label: "Write", provenance: "suggestion", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }, @@ -89,7 +89,7 @@ describe("LabelHistory", () => { const [visible, set_visible] = createSignal(false); - render(() => ); + render(() => ); vi.setSystemTime(new Date(2026, 3, 6, 10, 0, 0, 0)); const next_today = date_today_str(); diff --git a/src/taskclf/ui/frontend/src/components/LabelHistory.tsx b/src/taskclf/ui/frontend/src/components/LabelHistory.tsx index fe69582..e2ebbc8 100644 --- a/src/taskclf/ui/frontend/src/components/LabelHistory.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelHistory.tsx @@ -30,7 +30,7 @@ import { LabelHistoryTimeline } from "./LabelHistoryTimeline"; export const LabelHistory: Component<{ visible: Accessor; - label_change_count?: Accessor; + label_change_count: Accessor | undefined; }> = (props) => { const [selected_date, set_selected_date] = createSignal(date_today_str()); const [known_today, set_known_today] = createSignal(selected_date()); @@ -75,7 +75,7 @@ export const LabelHistory: Component<{ date_str: effective_selected_date(), label_change_count: props.label_change_count?.() ?? 0, } - : null, + : undefined, async (source) => { if (!source) { return []; @@ -85,10 +85,10 @@ export const LabelHistory: Component<{ ); const [coreLabels] = createResource(core_labels_list); - const [expanded_key, set_expanded_key] = createSignal(null); + const [expanded_key, set_expanded_key] = createSignal(undefined); const [busy, set_busy] = createSignal(false); - const [flash, set_flash] = createSignal(null); - const [error, set_error] = createSignal(null); + const [flash, set_flash] = createSignal(undefined); + const [error, set_error] = createSignal(undefined); const day_data = createMemo(() => { const l = labels(); @@ -103,9 +103,9 @@ export const LabelHistory: Component<{ function row_toggle(item: TimelineItem) { const key = item_key(item); - set_expanded_key(expanded_key() === key ? null : key); - set_flash(null); - set_error(null); + set_expanded_key(expanded_key() === key ? undefined : key); + set_flash(undefined); + set_error(undefined); } async function label_update_submit( @@ -115,8 +115,8 @@ export const LabelHistory: Component<{ new_end: string, ) { set_busy(true); - set_flash(null); - set_error(null); + set_flash(undefined); + set_error(undefined); try { await label_update({ start_ts: item.start_ts, @@ -124,11 +124,12 @@ export const LabelHistory: Component<{ label: new_label, new_start_ts: new_start, new_end_ts: new_end, + extend_forward: undefined, }); set_flash(new_label); setTimeout(() => { - set_flash(null); - set_expanded_key(null); + set_flash(undefined); + set_expanded_key(undefined); refetch(); }, 800); } catch (err: unknown) { @@ -140,14 +141,14 @@ export const LabelHistory: Component<{ async function label_delete_submit(item: LabelItem) { set_busy(true); - set_flash(null); - set_error(null); + set_flash(undefined); + set_error(undefined); try { await label_delete({ start_ts: item.start_ts, end_ts: item.end_ts, }); - set_expanded_key(null); + set_expanded_key(undefined); refetch(); } catch (err: unknown) { set_error(err instanceof Error ? err.message : String(err)); @@ -158,18 +159,23 @@ export const LabelHistory: Component<{ async function gap_create_submit(start_ts: string, end_ts: string, label: string) { set_busy(true); - set_flash(null); - set_error(null); + set_flash(undefined); + set_error(undefined); try { await label_create({ start_ts, end_ts, label, + user_id: undefined, + confidence: undefined, + extend_forward: undefined, + overwrite: undefined, + allow_overlap: undefined, }); set_flash(label); setTimeout(() => { - set_flash(null); - set_expanded_key(null); + set_flash(undefined); + set_expanded_key(undefined); refetch(); }, 800); } catch (err: unknown) { @@ -320,9 +326,9 @@ export const LabelHistory: Component<{ return; } const key = item_key(item); - set_expanded_key(expanded_key() === key ? null : key); - set_flash(null); - set_error(null); + set_expanded_key(expanded_key() === key ? undefined : key); + set_flash(undefined); + set_error(undefined); }} /> @@ -338,9 +344,9 @@ export const LabelHistory: Component<{ on_create={gap_create_submit} core_labels={coreLabels() ?? []} busy={busy()} - flash={expanded_key() === item_key(item) ? flash() : null} - error={expanded_key() === item_key(item) ? error() : null} - on_error_close={() => set_error(null)} + flash={expanded_key() === item_key(item) ? flash() : undefined} + error={expanded_key() === item_key(item) ? error() : undefined} + on_error_close={() => set_error(undefined)} /> } > @@ -355,9 +361,9 @@ export const LabelHistory: Component<{ on_delete={() => label_delete_submit(item as LabelItem)} core_labels={coreLabels() ?? []} busy={busy()} - flash={expanded_key() === item_key(item) ? flash() : null} - error={expanded_key() === item_key(item) ? error() : null} - on_error_close={() => set_error(null)} + flash={expanded_key() === item_key(item) ? flash() : undefined} + error={expanded_key() === item_key(item) ? error() : undefined} + on_error_close={() => set_error(undefined)} /> )} diff --git a/src/taskclf/ui/frontend/src/components/LabelHistoryGapRow.tsx b/src/taskclf/ui/frontend/src/components/LabelHistoryGapRow.tsx index 8f2a25b..56d5756 100644 --- a/src/taskclf/ui/frontend/src/components/LabelHistoryGapRow.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelHistoryGapRow.tsx @@ -21,8 +21,8 @@ export const LabelHistoryGapRow: Component<{ on_create: (start: string, end: string, label: string) => void; core_labels: string[]; busy: boolean; - flash: string | null; - error: string | null; + flash: string | undefined; + error: string | undefined; on_error_close: () => void; }> = (props) => { const gap_start_d = () => date_parse(props.gap.start_ts); @@ -56,16 +56,16 @@ export const LabelHistoryGapRow: Component<{ set_end_iso(time_input_date(props.date_str, val).toISOString()); } - const selected_range = (): TimeRange | null => { + const selected_range = (): TimeRange | undefined => { const s = date_parse(start_iso()).getTime(); const e = date_parse(end_iso()).getTime(); if (e <= s) { - return null; + return undefined; } return { start: start_iso(), end: end_iso() }; }; - const range_valid = () => selected_range() !== null; + const range_valid = () => selected_range() !== undefined; const time_input_style = { background: "#111", @@ -179,7 +179,12 @@ export const LabelHistoryGapRow: Component<{ /> - selected_range()} /> + selected_range()} + prediction={undefined} + show_empty={undefined} + /> diff --git a/src/taskclf/ui/frontend/src/components/LabelHistoryRow.tsx b/src/taskclf/ui/frontend/src/components/LabelHistoryRow.tsx index 2d8ba0f..5895638 100644 --- a/src/taskclf/ui/frontend/src/components/LabelHistoryRow.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelHistoryRow.tsx @@ -22,8 +22,8 @@ export const LabelHistoryRow: Component<{ on_delete: () => void; core_labels: string[]; busy: boolean; - flash: string | null; - error: string | null; + flash: string | undefined; + error: string | undefined; on_error_close: () => void; }> = (props) => { const is_open_ended = () => props.label_item.open_ended === true; @@ -34,7 +34,9 @@ export const LabelHistoryRow: Component<{ ? "until next label" : duration_fmt(end_d().getTime() - start_d().getTime()); const [confirm_delete, set_confirm_delete] = createSignal(false); - const [pending_label, set_pending_label] = createSignal(null); + const [pending_label, set_pending_label] = createSignal( + undefined, + ); const [start_time, set_start_time] = createSignal(time_input_value(start_d())); const [end_time, set_end_time] = createSignal(time_input_value(end_d())); @@ -44,23 +46,23 @@ export const LabelHistoryRow: Component<{ set_end_time(time_input_value(end_d())); }); - const edited_range = (): TimeRange | null => { + const edited_range = (): TimeRange | undefined => { const start = time_input_date(props.date_str, start_time()); const end = time_input_date(props.date_str, end_time()); if (end.getTime() <= start.getTime()) { - return null; + return undefined; } return { start: start.toISOString(), end: end.toISOString() }; }; - const range_valid = () => edited_range() !== null; + const range_valid = () => edited_range() !== undefined; const time_changed = () => start_time() !== time_input_value(start_d()) || end_time() !== time_input_value(end_d()); const label_changed = () => - pending_label() !== null && pending_label() !== props.label_item.label; + pending_label() !== undefined && pending_label() !== props.label_item.label; const effective_label = () => pending_label() ?? props.label_item.label; const has_changes = () => label_changed() || time_changed(); @@ -204,7 +206,12 @@ export const LabelHistoryRow: Component<{ - edited_range()} /> + edited_range()} + prediction={undefined} + show_empty={undefined} + /> @@ -245,7 +252,7 @@ export const LabelHistoryRow: Component<{ onClick={(e) => { e.stopPropagation(); if (label_name === props.label_item.label) { - set_pending_label(null); + set_pending_label(undefined); } else { set_pending_label(label_name); } @@ -335,7 +342,7 @@ export const LabelHistoryRow: Component<{ disabled={props.busy} onClick={(e) => { e.stopPropagation(); - set_pending_label(null); + set_pending_label(undefined); set_start_time(time_input_value(start_d())); set_end_time(time_input_value(end_d())); }} @@ -359,7 +366,7 @@ export const LabelHistoryRow: Component<{ const r = edited_range(); if (r) { const label = effective_label(); - set_pending_label(null); + set_pending_label(undefined); props.on_update(label, r.start, r.end); } }} diff --git a/src/taskclf/ui/frontend/src/components/LabelHistoryTimeline.tsx b/src/taskclf/ui/frontend/src/components/LabelHistoryTimeline.tsx index 8cd46dd..9351264 100644 --- a/src/taskclf/ui/frontend/src/components/LabelHistoryTimeline.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelHistoryTimeline.tsx @@ -5,9 +5,11 @@ import type { TimelineSegment } from "../lib/labelTimeline"; export const LabelHistoryTimeline: Component<{ segments: TimelineSegment[]; - on_segment_click?: (seg: TimelineSegment, index: number) => void; + on_segment_click: ((seg: TimelineSegment, index: number) => void) | undefined; }> = (props) => { - const [tooltip, set_tooltip] = createSignal<{ text: string; x: number } | null>(null); + const [tooltip, set_tooltip] = createSignal<{ text: string; x: number } | undefined>( + undefined, + ); return (
@@ -55,7 +57,7 @@ export const LabelHistoryTimeline: Component<{ } }} onMouseLeave={(e) => { - set_tooltip(null); + set_tooltip(undefined); if (!seg.label) { e.currentTarget.style.background = "rgba(255,255,255,0.04)"; } diff --git a/src/taskclf/ui/frontend/src/components/LabelLast.tsx b/src/taskclf/ui/frontend/src/components/LabelLast.tsx index b81a0b2..9b2c818 100644 --- a/src/taskclf/ui/frontend/src/components/LabelLast.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelLast.tsx @@ -9,12 +9,12 @@ type LabelLastProps = { label: string; start_ts: string; end_ts: string; - extend_forward?: boolean; + extend_forward: boolean | undefined; } - | null + | undefined | undefined >; - is_current?: Accessor; + is_current: Accessor | undefined; }; const LabelLastContent: Component<{ @@ -22,9 +22,9 @@ const LabelLastContent: Component<{ label: string; start_ts: string; end_ts: string; - extend_forward?: boolean; + extend_forward: boolean | undefined; }>; - is_current?: Accessor; + is_current: Accessor | undefined; }> = (props) => { const is_current = () => props.is_current?.() ?? label_entry_is_open_ended(props.ll()); diff --git a/src/taskclf/ui/frontend/src/components/LabelQueue.tsx b/src/taskclf/ui/frontend/src/components/LabelQueue.tsx index 5572d50..0b72560 100644 --- a/src/taskclf/ui/frontend/src/components/LabelQueue.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelQueue.tsx @@ -71,7 +71,7 @@ export const LabelQueue: Component = () => { > {item.reason} {item.predicted_label && ` · ${item.predicted_label}`} - {item.confidence !== null + {item.confidence !== undefined && ` · ${Math.round(item.confidence * 100)}%`}
diff --git a/src/taskclf/ui/frontend/src/components/LabelRecorder.test.tsx b/src/taskclf/ui/frontend/src/components/LabelRecorder.test.tsx index 414757d..82f0523 100644 --- a/src/taskclf/ui/frontend/src/components/LabelRecorder.test.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelRecorder.test.tsx @@ -22,17 +22,27 @@ vi.mock("./ActivitySummary", () => ({ })); vi.mock("./PredictionSuggestion", () => ({ - PredictionSuggestion: () => null, + PredictionSuggestion: () => undefined, })); beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); vi.mocked(core_labels_list).mockResolvedValue(["Build", "Write"]); - vi.mocked(current_label_get).mockResolvedValue(null); + vi.mocked(current_label_get).mockResolvedValue(undefined); }); describe("LabelRecorder", () => { + const base_props = { + max_height: undefined, + prediction: undefined, + suggestion: undefined, + suggestions: undefined, + label_change_count: undefined, + on_suggestion_dismiss: undefined, + on_suggestion_select: undefined, + } as const; + it("shows a stop action for the current open-ended label and ends it at click time", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-05T10:00:00Z")); @@ -43,7 +53,7 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T09:00:00Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: true, }, @@ -54,7 +64,7 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T10:00:00.000Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }, @@ -65,22 +75,22 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T09:00:00Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: true, }) - .mockResolvedValueOnce(null); + .mockResolvedValueOnce(undefined); vi.mocked(label_update).mockResolvedValue({ start_ts: "2026-04-05T09:00:00Z", end_ts: "2026-04-05T10:00:00.000Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }); - render(() => ); + render(() => ); expect(await screen.findByText(/^Current:/)).toBeInTheDocument(); @@ -123,7 +133,7 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T09:05:00Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: true, }, @@ -133,12 +143,12 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T09:05:00Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: true, }); - render(() => ); + render(() => ); expect(await screen.findByText(/^Current:/)).toBeInTheDocument(); expect( @@ -153,7 +163,7 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T10:00:00Z", label: "Write", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }, @@ -163,12 +173,12 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T09:00:00Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: true, }); - render(() => ); + render(() => ); expect(await screen.findByText(/^Current:/)).toBeInTheDocument(); expect( @@ -184,13 +194,13 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T09:30:00Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }, ]); - render(() => ); + render(() => ); await waitFor(() => { expect(labels_list).toHaveBeenCalledWith(1); @@ -216,7 +226,7 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T09:30:00Z", label: "Build", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: false, }, @@ -226,12 +236,12 @@ describe("LabelRecorder", () => { end_ts: "2026-04-05T11:00:00.000Z", label: "Write", provenance: "manual", - user_id: null, + user_id: undefined, confidence: 1, extend_forward: true, }); - render(() => ); + render(() => ); await waitFor(() => { expect(screen.getByRole("button", { name: "gap 1h30m" })).toBeInTheDocument(); diff --git a/src/taskclf/ui/frontend/src/components/LabelRecorder.tsx b/src/taskclf/ui/frontend/src/components/LabelRecorder.tsx index 854739b..202ff26 100644 --- a/src/taskclf/ui/frontend/src/components/LabelRecorder.tsx +++ b/src/taskclf/ui/frontend/src/components/LabelRecorder.tsx @@ -42,17 +42,19 @@ function extend_forward_pref_read(): boolean { } type LabelRecorderProps = { - max_height?: number; + max_height: number | undefined; on_collapse: () => void; - prediction?: Accessor; - suggestion?: Accessor; - suggestions?: Accessor; - label_change_count?: Accessor; - on_suggestion_dismiss?: ( - reason?: SuggestionClearReason, - suggestion?: LabelSuggestion | null, - ) => void; - on_suggestion_select?: (suggestion: LabelSuggestion) => void; + prediction: Accessor | undefined; + suggestion: Accessor | undefined; + suggestions: Accessor | undefined; + label_change_count: Accessor | undefined; + on_suggestion_dismiss: + | (( + reason: SuggestionClearReason | undefined, + suggestion: LabelSuggestion | undefined, + ) => void) + | undefined; + on_suggestion_select: ((suggestion: LabelSuggestion) => void) | undefined; }; export const LabelRecorder: Component = (props) => { @@ -63,22 +65,23 @@ export const LabelRecorder: Component = (props) => { const [last_ended_label] = createResource(label_refresh_key, async () => { try { const rows = await labels_list(1); - return rows.length ? rows[0] : null; + return rows.length ? rows[0] : undefined; } catch { - return null; + return undefined; } }); const [current_label_result] = createResource(label_refresh_key, async () => { try { return await current_label_get(); } catch { - return null; + return undefined; } }); - const [flash, set_flash] = createSignal(null); - const [error, set_error] = createSignal(null); - const [overwrite_pending, set_overwrite_pending] = - createSignal(null); + const [flash, set_flash] = createSignal(undefined); + const [error, set_error] = createSignal(undefined); + const [overwrite_pending, set_overwrite_pending] = createSignal< + OverwritePending | undefined + >(undefined); const [selected_minutes, set_selected_minutes] = createSignal(0); const [extend_fwd, set_extend_fwd] = createSignal(extend_forward_pref_read()); const [fill_from_last, set_fill_from_last] = createSignal(false); @@ -86,16 +89,16 @@ export const LabelRecorder: Component = (props) => { const [stop_current_pending, set_stop_current_pending] = createSignal(false); const [stop_current_busy, set_stop_current_busy] = createSignal(false); - const current_label = () => current_label_result() ?? null; + const current_label = () => current_label_result() ?? undefined; - const footer_label = () => current_label() ?? last_ended_label() ?? null; + const footer_label = () => current_label() ?? last_ended_label() ?? undefined; createEffect( on( () => [last_ended_label(), current_label()] as const, () => { if (overwrite_pending()) { - set_overwrite_pending(null); + set_overwrite_pending(undefined); } if (stop_current_pending()) { set_stop_current_pending(false); @@ -119,7 +122,7 @@ export const LabelRecorder: Component = (props) => { { selected_minutes: selected_minutes(), fill_from_last: fill_from_last(), - last_label_end_ts: last_ended_label()?.end_ts ?? null, + last_label_end_ts: last_ended_label()?.end_ts ?? undefined, extend_fwd: extend_fwd(), }, new Date(), @@ -155,18 +158,21 @@ export const LabelRecorder: Component = (props) => { start = new Date(now.getTime() - mins * 60_000); } const effective_extend = force_extend_fwd || extend_fwd(); - set_error(null); + set_error(undefined); try { await label_create({ start_ts: start.toISOString(), end_ts: now.toISOString(), label, + user_id: undefined, confidence: conf_percent() / 100, extend_forward: effective_extend, + overwrite: undefined, + allow_overlap: undefined, }); set_flash(label); set_label_version((v) => v + 1); - setTimeout(() => set_flash(null), 1500); + setTimeout(() => set_flash(undefined), 1500); } catch (err: unknown) { const pending = overwrite_pending_from_api_error(err, { label, @@ -189,20 +195,22 @@ export const LabelRecorder: Component = (props) => { if (!pending) { return; } - set_overwrite_pending(null); - set_error(null); + set_overwrite_pending(undefined); + set_error(undefined); try { await label_create({ start_ts: pending.start, end_ts: pending.end, label: pending.label, + user_id: undefined, confidence: pending.confidence, extend_forward: pending.extend_forward, overwrite: true, + allow_overlap: undefined, }); set_flash(pending.label); set_label_version((v) => v + 1); - setTimeout(() => set_flash(null), 1500); + setTimeout(() => set_flash(undefined), 1500); } catch (err: unknown) { set_error(err instanceof Error ? err.message : "overwrite failed"); } @@ -213,20 +221,22 @@ export const LabelRecorder: Component = (props) => { if (!pending) { return; } - set_overwrite_pending(null); - set_error(null); + set_overwrite_pending(undefined); + set_error(undefined); try { await label_create({ start_ts: pending.start, end_ts: pending.end, label: pending.label, + user_id: undefined, confidence: pending.confidence, extend_forward: pending.extend_forward, + overwrite: undefined, allow_overlap: true, }); set_flash(pending.label); set_label_version((v) => v + 1); - setTimeout(() => set_flash(null), 1500); + setTimeout(() => set_flash(undefined), 1500); } catch (err: unknown) { set_error(err instanceof Error ? err.message : "keep all failed"); } @@ -242,13 +252,14 @@ export const LabelRecorder: Component = (props) => { const stop_ts = new Date(now_ms <= start_ms ? start_ms + 1 : now_ms).toISOString(); set_stop_current_busy(true); - set_error(null); - set_flash(null); + set_error(undefined); + set_flash(undefined); try { await label_update({ start_ts: current.start_ts, end_ts: current.end_ts, label: current.label, + new_start_ts: undefined, new_end_ts: stop_ts, extend_forward: false, }); @@ -266,14 +277,14 @@ export const LabelRecorder: Component = (props) => { style={{ padding: "8px", "border-top": "1px solid var(--border)", - ...(props.max_height != null + ...(props.max_height !== undefined ? { "max-height": `${props.max_height}px`, "overflow-y": "auto" } : {}), }} > null)} + suggestion={props.suggestion ?? (() => undefined)} suggestions={props.suggestions} on_saved={() => set_label_version((v) => v + 1)} on_dismiss={props.on_suggestion_dismiss} @@ -286,12 +297,16 @@ export const LabelRecorder: Component = (props) => { set_selected_minutes={set_selected_minutes} fill_from_last={fill_from_last} set_fill_from_last={set_fill_from_last} - has_current_label={() => current_label() != null} + has_current_label={() => current_label() !== undefined} last_label={last_ended_label} /> - - + @@ -304,7 +319,7 @@ export const LabelRecorder: Component = (props) => { pending={pending()} on_confirm={overwrite_confirm} on_keep_all={keep_all_confirm} - on_cancel={() => set_overwrite_pending(null)} + on_cancel={() => set_overwrite_pending(undefined)} /> )} @@ -312,7 +327,7 @@ export const LabelRecorder: Component = (props) => { - - ); - }} -
- - - + {(active) => (
-
-
- - Model suggests a change:{" "} - - - {s()?.old_label} - - - - {s()?.suggested} - - - {" "} - ({Math.round((s()?.confidence ?? 0) * 100)}%) - -
-
- {suggestion_range_format(s()?.block_start, s()?.block_end)} -
-
-
- - - -
-
- -
+ 1}>
- What should this time block be labeled instead? -
- - Loading label choices... -
- } - > -
+ Model suggestions ({pending_count()} pending) + + {suggestion_position()} of {pending_count()} + + +
    - - {(label_name) => { - const is_selected = () => label_name === correction_label(); - const is_suggested = () => label_name === s()?.suggested; + + {(item, index) => { + const is_active = () => label_suggestion_key(item) === active_key(); return ( - + {item.suggested} + + ); }} -
-
+ + + +
+
+
+ + Model suggests a change:{" "} + + + {active().old_label} + + + + {active().suggested} + + + {" "} + ({Math.round(active().confidence * 100)}%) + +
+
+ {suggestion_range_format(active().block_start, active().block_end)} +
+
+
- - - - {(pending) => ( - set_overwrite_pending(null)} - /> - )} - - - set_error(null)} /> - - + +
+
+ What should this time block be labeled instead? +
+ + Loading label choices... +
+ } + > +
+ + {(label_name) => { + const is_selected = () => label_name === correction_label(); + const is_suggested = () => label_name === active().suggested; + return ( + + ); + }} + +
+
+
+ + +
+ + + + + {(pending) => ( + set_overwrite_pending(undefined)} + /> + )} + + + {(message) => ( + set_error(undefined)} /> + )} + + + )} ); }; diff --git a/src/taskclf/ui/frontend/src/components/StatusPanel.tsx b/src/taskclf/ui/frontend/src/components/StatusPanel.tsx index edef34c..9126ff4 100644 --- a/src/taskclf/ui/frontend/src/components/StatusPanel.tsx +++ b/src/taskclf/ui/frontend/src/components/StatusPanel.tsx @@ -23,14 +23,14 @@ import { TrainingPanel } from "./TrainingPanel"; export const StatusPanel: Component<{ status: Accessor; latest_status: Accessor; - latest_prediction: Accessor; + latest_prediction: Accessor; latest_tray_state: Accessor; - active_suggestion: Accessor; - pending_suggestions?: Accessor; - label_change_count?: Accessor; + active_suggestion: Accessor; + pending_suggestions: Accessor | undefined; + label_change_count: Accessor | undefined; ws_stats: Accessor; train_state: Accessor; - on_open_label_recorder?: () => void; + on_open_label_recorder: (() => void) | undefined; }> = (props) => { const [tab, set_tab] = createSignal("system"); diff --git a/src/taskclf/ui/frontend/src/components/TrainingPanel.tsx b/src/taskclf/ui/frontend/src/components/TrainingPanel.tsx index ca7d9df..d68091d 100644 --- a/src/taskclf/ui/frontend/src/components/TrainingPanel.tsx +++ b/src/taskclf/ui/frontend/src/components/TrainingPanel.tsx @@ -24,6 +24,13 @@ import { StatusProgress } from "./ui/StatusProgress"; import { StatusRow } from "./ui/StatusRow"; import { StatusSection } from "./ui/StatusSection"; +const STATUS_ROW_DEFAULTS = { + color: undefined, + dim: undefined, + mono: undefined, + tooltip: undefined, +} as const; + export const TrainingPanel: Component<{ train_state: Accessor; }> = (props) => { @@ -42,40 +49,43 @@ export const TrainingPanel: Component<{ ); const [synthetic, set_synthetic] = createSignal(false); - const [data_check, set_data_check] = createSignal(null); - const [checked_range, set_checked_range] = createSignal<{ - from: string; - to: string; - } | null>(null); + const [data_check, set_data_check] = createSignal(undefined); + const [checked_range, set_checked_range] = createSignal< + | { + from: string; + to: string; + } + | undefined + >(undefined); const [models, set_models] = createSignal([]); const [checking, set_checking] = createSignal(false); - const [check_error, set_check_error] = createSignal(null); - const [train_error, set_train_error] = createSignal(null); + const [check_error, set_check_error] = createSignal(undefined); + const [train_error, set_train_error] = createSignal(undefined); const [submitting, set_submitting] = createSignal(false); const [confirm_pending, set_confirm_pending] = createSignal(false); const [dismissed_run_error_key, set_dismissed_run_error_key] = createSignal< - string | null - >(null); + string | undefined + >(undefined); - const [expanded_bundle_id, set_expanded_bundle_id] = createSignal( - null, + const [expanded_bundle_id, set_expanded_bundle_id] = createSignal( + undefined, ); const [bundle_inspect_by_id, set_bundle_inspect_by_id] = createSignal< Record >({}); const [bundle_inspect_loading_id, set_bundle_inspect_loading_id] = createSignal< - string | null - >(null); + string | undefined + >(undefined); const ts = () => props.train_state(); const is_running = () => ts().status === "running"; const run_error_key = () => - ts().error ? `${ts().job_id ?? "no-job"}:${ts().error}` : null; + ts().error ? `${ts().job_id ?? "no-job"}:${ts().error}` : undefined; const visible_run_error = createMemo(() => { const error = ts().error; const key = run_error_key(); if (!error || key === dismissed_run_error_key()) { - return null; + return undefined; } return error; }); @@ -84,9 +94,9 @@ export const TrainingPanel: Component<{ on( () => [date_from(), date_to()], () => { - set_data_check(null); - set_checked_range(null); - set_check_error(null); + set_data_check(undefined); + set_checked_range(undefined); + set_check_error(undefined); }, { defer: true }, ), @@ -107,8 +117,8 @@ export const TrainingPanel: Component<{ on( run_error_key, (key) => { - if (key === null) { - set_dismissed_run_error_key(null); + if (key === undefined) { + set_dismissed_run_error_key(undefined); } }, { defer: true }, @@ -128,7 +138,7 @@ export const TrainingPanel: Component<{ const train_disabled_reason = createMemo(() => { if (synthetic()) { - return null; + return undefined; } const dc = data_check(); if (!dc) { @@ -143,7 +153,7 @@ export const TrainingPanel: Component<{ if (dc.trainable_rows === 0) { return "Labels don't overlap any feature windows — adjust labels or date range"; } - return null; + return undefined; }); function models_sorted(ml: ModelBundle[]) { @@ -168,7 +178,7 @@ export const TrainingPanel: Component<{ async function toggle_bundle_inspect(model_id: string) { if (expanded_bundle_id() === model_id) { - set_expanded_bundle_id(null); + set_expanded_bundle_id(undefined); return; } set_expanded_bundle_id(model_id); @@ -186,7 +196,7 @@ export const TrainingPanel: Component<{ [model_id]: { error: msg }, })); } finally { - set_bundle_inspect_loading_id(null); + set_bundle_inspect_loading_id(undefined); } } @@ -195,7 +205,7 @@ export const TrainingPanel: Component<{ return; } set_checking(true); - set_check_error(null); + set_check_error(undefined); try { const [dc, ml] = await Promise.all([ training_data_check(date_from(), date_to()), @@ -223,7 +233,7 @@ export const TrainingPanel: Component<{ } set_confirm_pending(false); set_submitting(true); - set_train_error(null); + set_train_error(undefined); try { await training_start({ date_from: date_from(), @@ -281,7 +291,12 @@ export const TrainingPanel: Component<{ return (
- +
diff --git a/src/taskclf/ui/frontend/src/components/status/StatusConfig.tsx b/src/taskclf/ui/frontend/src/components/status/StatusConfig.tsx index 3392aa8..a1581d9 100644 --- a/src/taskclf/ui/frontend/src/components/status/StatusConfig.tsx +++ b/src/taskclf/ui/frontend/src/components/status/StatusConfig.tsx @@ -4,6 +4,13 @@ import type { TrayState } from "../../lib/ws"; import { StatusRow } from "../ui/StatusRow"; import { StatusSection } from "../ui/StatusSection"; +const STATUS_ROW_DEFAULTS = { + color: undefined, + dim: undefined, + mono: undefined, + tooltip: undefined, +} as const; + export const StatusConfig: Component<{ tray_state: Accessor; }> = (props) => { @@ -12,8 +19,14 @@ export const StatusConfig: Component<{ const summary = createMemo(() => (t().dev_mode ? "dev" : "prod")); return ( - + ; }> = (props) => { const t = () => props.tray_state(); - const [bundle_inspect, set_bundle_inspect] = - createSignal(null); - const [bundle_inspect_error, set_bundle_inspect_error] = createSignal( - null, - ); + const [bundle_inspect, set_bundle_inspect] = createSignal< + CurrentModelBundleInspectResponse | undefined + >(undefined); + const [bundle_inspect_error, set_bundle_inspect_error] = createSignal< + string | undefined + >(undefined); createEffect( on( () => [t().model_loaded, t().model_dir] as const, async ([loaded, dir]) => { if (!loaded || !dir) { - set_bundle_inspect(null); - set_bundle_inspect_error(null); + set_bundle_inspect(undefined); + set_bundle_inspect_error(undefined); return; } try { const r = await model_bundle_inspect_current(); set_bundle_inspect(r); - set_bundle_inspect_error(null); + set_bundle_inspect_error(undefined); } catch (e: unknown) { - set_bundle_inspect(null); + set_bundle_inspect(undefined); set_bundle_inspect_error( e instanceof Error ? e.message : "Bundle inspect failed", ); @@ -64,8 +72,14 @@ export const StatusModel: Component<{ }); return ( - + {(dir) => ( {(hash) => ( ( <> ; on_change: Setter; - history_pending?: Accessor; - on_history_pending_click?: () => void; + history_pending: Accessor | undefined; + on_history_pending_click: (() => void) | undefined; }> = (props) => (
; + prediction: Accessor; }> = (props) => { const pred = () => props.prediction(); @@ -34,6 +41,7 @@ export const StatusPrediction: Component<{ when={pred()} fallback={ ( <> = 0.5 ? "#22c55e" : "#ef4444"} tooltip="Model's confidence in the prediction (higher is better)" /> {(app) => ( ; - pending_count?: Accessor; + suggestion: Accessor; + pending_count: Accessor | undefined; }> = (props) => { const sug = () => props.suggestion(); @@ -31,36 +38,43 @@ export const StatusSuggestion: Component<{ title="Active Suggestion" summary={summary()} summary_color={summary_color()} + default_open={undefined} > ; }> = (props) => { @@ -12,8 +19,14 @@ export const StatusTransitions: Component<{ const summary = createMemo(() => String(t().transition_count)); return ( - + ( <> ; ws_stats: Accessor; @@ -19,20 +26,24 @@ export const StatusWebSocket: Component<{ title="WebSocket" summary={summary()} summary_color={summary_color()} + default_open={undefined} > = (props) => ( +export const StatusProgress: Component<{ pct: number; color: string | undefined }> = ( + props, +) => (
= (props) => (
= (props) => { const [open, set_open] = createSignal(props.default_open ?? false); diff --git a/src/taskclf/ui/frontend/src/lib/api.ts b/src/taskclf/ui/frontend/src/lib/api.ts index a2a8177..a09c610 100644 --- a/src/taskclf/ui/frontend/src/lib/api.ts +++ b/src/taskclf/ui/frontend/src/lib/api.ts @@ -1,3 +1,5 @@ +import { null_to_undefined } from "./nullish"; + const BASE = "/api"; export type LabelResponse = { @@ -5,8 +7,8 @@ export type LabelResponse = { end_ts: string; label: string; provenance: string; - user_id: string | null; - confidence: number | null; + user_id: string | undefined; + confidence: number | undefined; extend_forward: boolean; }; @@ -16,16 +18,16 @@ export type QueueItem = { bucket_start_ts: string; bucket_end_ts: string; reason: string; - confidence: number | null; - predicted_label: string | null; + confidence: number | undefined; + predicted_label: string | undefined; status: string; }; export type FeatureSummary = { top_apps: { app_id: string; buckets: number }[]; - mean_keys_per_min: number | null; - mean_clicks_per_min: number | null; - mean_scroll_per_min: number | null; + mean_keys_per_min: number | undefined; + mean_clicks_per_min: number | undefined; + mean_scroll_per_min: number | undefined; total_buckets: number; session_count: number; }; @@ -36,7 +38,7 @@ export type ActivityProviderStatus = { state: "checking" | "ready" | "setup_required"; summary_available: boolean; endpoint: string; - source_id: string | null; + source_id: string | undefined; last_sample_count: number; last_sample_breakdown: Record; setup_title: string; @@ -54,23 +56,26 @@ export type ActivitySummary = FeatureSummary & { activity_provider: ActivityProviderStatus; recent_apps: AWLiveEntry[]; range_state: "ok" | "no_data" | "provider_unavailable"; - message: string | null; + message: string | undefined; }; -async function api_json(url: string, init?: RequestInit): Promise { +async function api_json( + url: string, + init: RequestInit | undefined = undefined, +): Promise { const res = await fetch(url, init); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`${res.status}: ${text}`); } - return res.json(); + return null_to_undefined(await res.json()); } export async function labels_list(limit = 50): Promise { return api_json(`${BASE}/labels?limit=${limit}`); } -export async function current_label_get(): Promise { +export async function current_label_get(): Promise { return api_json(`${BASE}/labels/current`); } @@ -86,11 +91,11 @@ export async function label_create(body: { start_ts: string; end_ts: string; label: string; - user_id?: string; - confidence?: number; - extend_forward?: boolean; - overwrite?: boolean; - allow_overlap?: boolean; + user_id: string | undefined; + confidence: number | undefined; + extend_forward: boolean | undefined; + overwrite: boolean | undefined; + allow_overlap: boolean | undefined; }): Promise { return api_json(`${BASE}/labels`, { method: "POST", @@ -142,9 +147,9 @@ export async function label_update(body: { start_ts: string; end_ts: string; label: string; - new_start_ts?: string; - new_end_ts?: string; - extend_forward?: boolean; + new_start_ts: string | undefined; + new_end_ts: string | undefined; + extend_forward: boolean | undefined; }): Promise { return api_json(`${BASE}/labels`, { method: "PUT", @@ -169,12 +174,12 @@ export async function core_labels_list(): Promise { } export async function notification_accept(body: { - suggestion_id?: string; + suggestion_id: string | undefined; block_start: string; block_end: string; label: string; - overwrite?: boolean; - allow_overlap?: boolean; + overwrite: boolean | undefined; + allow_overlap: boolean | undefined; }): Promise { return api_json(`${BASE}/notification/accept`, { method: "POST", @@ -183,9 +188,9 @@ export async function notification_accept(body: { }); } -export async function notification_skip(body?: { - suggestion_id?: string; -}): Promise<{ status: string }> { +export async function notification_skip( + body: { suggestion_id: string | undefined } = { suggestion_id: undefined }, +): Promise<{ status: string }> { return api_json(`${BASE}/notification/skip`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -206,9 +211,9 @@ export async function user_config_get(): Promise { } export async function user_config_update(patch: { - username?: string; - suggestion_banner_ttl_seconds?: number; - auto_save_suggestion_min_confidence?: number; + username: string | undefined; + suggestion_banner_ttl_seconds: number | undefined; + auto_save_suggestion_min_confidence: number | undefined; }): Promise { return api_json(`${BASE}/config/user`, { method: "PUT", @@ -220,26 +225,26 @@ export async function user_config_update(patch: { // -- Training ---------------------------------------------------------------- export type TrainStatus = { - job_id: string | null; + job_id: string | undefined; status: "idle" | "running" | "complete" | "failed"; - step: string | null; - progress_pct: number | null; - message: string | null; - error: string | null; - metrics: Record | null; - model_dir: string | null; - started_at: string | null; - finished_at: string | null; + step: string | undefined; + progress_pct: number | undefined; + message: string | undefined; + error: string | undefined; + metrics: Record | undefined; + model_dir: string | undefined; + started_at: string | undefined; + finished_at: string | undefined; }; export type ModelBundle = { model_id: string; path: string; valid: boolean; - invalid_reason: string | null; - macro_f1: number | null; - weighted_f1: number | null; - created_at: string | null; + invalid_reason: string | undefined; + macro_f1: number | undefined; + weighted_f1: number | undefined; + created_at: string | undefined; }; export type DataCheck = { @@ -258,9 +263,9 @@ export type DataCheck = { export async function training_start(params: { date_from: string; date_to: string; - num_boost_round?: number; - class_weight?: "balanced" | "none"; - synthetic?: boolean; + num_boost_round: number | undefined; + class_weight: "balanced" | "none" | undefined; + synthetic: boolean | undefined; }): Promise { return api_json(`${BASE}/train/start`, { method: "POST", diff --git a/src/taskclf/ui/frontend/src/lib/date.test.ts b/src/taskclf/ui/frontend/src/lib/date.test.ts index 3318394..fc57af4 100644 --- a/src/taskclf/ui/frontend/src/lib/date.test.ts +++ b/src/taskclf/ui/frontend/src/lib/date.test.ts @@ -5,7 +5,7 @@ describe("gap_shortcut_label_from_end", () => { it("returns null when under one rounded minute", () => { const end = Date.parse("2026-04-05T10:00:00.000Z"); const now = end + 29_000; - expect(gap_shortcut_label_from_end(end, now)).toBeNull(); + expect(gap_shortcut_label_from_end(end, now)).toBeUndefined(); }); it("formats minutes and hours like the gap button", () => { diff --git a/src/taskclf/ui/frontend/src/lib/date.ts b/src/taskclf/ui/frontend/src/lib/date.ts index 9f8a199..b38a1b3 100644 --- a/src/taskclf/ui/frontend/src/lib/date.ts +++ b/src/taskclf/ui/frontend/src/lib/date.ts @@ -84,16 +84,16 @@ export function time_input_date(dateStr: string, timeVal: string): Date { } /** - * Quick-label gap shortcut text from a label's end time, or `null` when the + * Quick-label gap shortcut text from a label's end time, or `undefined` when the * control should stay hidden (under one rounded minute since end). */ export function gap_shortcut_label_from_end( end_ms: number, now_ms: number, -): string | null { +): string | undefined { const ago = Math.round((now_ms - end_ms) / 60_000); if (ago < 1) { - return null; + return undefined; } if (ago >= 60) { return `gap ${Math.floor(ago / 60)}h${ago % 60 ? `${ago % 60}m` : ""}`; @@ -123,9 +123,9 @@ export type TimeRange = { end: string; }; -export function time_range_minutes(mins: number): TimeRange | null { +export function time_range_minutes(mins: number): TimeRange | undefined { if (mins < 1) { - return null; + return undefined; } const now = new Date(); const start = new Date(now.getTime() - mins * 60_000); diff --git a/src/taskclf/ui/frontend/src/lib/format.ts b/src/taskclf/ui/frontend/src/lib/format.ts index c42b647..518e820 100644 --- a/src/taskclf/ui/frontend/src/lib/format.ts +++ b/src/taskclf/ui/frontend/src/lib/format.ts @@ -12,7 +12,7 @@ export function duration_format(seconds: number): string { return rm > 0 ? `${h}h ${rm}m` : `${h}h`; } -export function time_format(iso: string | null | undefined): string { +export function time_format(iso: string | undefined | undefined): string { if (!iso) { return "—"; } @@ -28,7 +28,7 @@ export function time_format(iso: string | null | undefined): string { } } -export function path_trunc(p: string | null | undefined, maxLen = 30): string { +export function path_trunc(p: string | undefined | undefined, maxLen = 30): string { if (!p) { return "—"; } @@ -43,9 +43,9 @@ export function app_name_short(app: string): string { return parts[parts.length - 1]; } -export function rate_fmt(v: number | null): string | null { - if (v == null) { - return null; +export function rate_fmt(v: number | undefined): string | undefined { + if (v === undefined) { + return undefined; } return v < 10 ? v.toFixed(1) : String(Math.round(v)); } diff --git a/src/taskclf/ui/frontend/src/lib/host.test.ts b/src/taskclf/ui/frontend/src/lib/host.test.ts index 96ed057..8af26b9 100644 --- a/src/taskclf/ui/frontend/src/lib/host.test.ts +++ b/src/taskclf/ui/frontend/src/lib/host.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; function host_globals_clear() { - delete (window as typeof window & { electronHost?: unknown }).electronHost; - delete (window as typeof window & { pywebview?: unknown }).pywebview; + delete (window as typeof window & { electronHost: unknown | undefined }).electronHost; + delete (window as typeof window & { pywebview: unknown | undefined }).pywebview; } describe("host", () => { @@ -15,7 +15,9 @@ describe("host", () => { it("uses the Electron bridge when available", async () => { const electron_invoke = vi.fn().mockResolvedValue(undefined); ( - window as typeof window & { electronHost?: { invoke: typeof electron_invoke } } + window as typeof window & { + electronHost: { invoke: typeof electron_invoke } | undefined; + } ).electronHost = { invoke: electron_invoke, }; @@ -38,7 +40,9 @@ describe("host", () => { const dashboard_toggle = vi.fn().mockResolvedValue(undefined); ( window as typeof window & { - pywebview?: { api?: { dashboard_toggle: typeof dashboard_toggle } }; + pywebview: + | { api: { dashboard_toggle: typeof dashboard_toggle } | undefined } + | undefined; } ).pywebview = { api: { @@ -70,11 +74,15 @@ describe("host", () => { }; ( window as typeof window & { - pywebview?: { - api?: { - show_transition_notification: typeof show_transition_notification; - }; - }; + pywebview: + | { + api: + | { + show_transition_notification: typeof show_transition_notification; + } + | undefined; + } + | undefined; } ).pywebview = { api: { diff --git a/src/taskclf/ui/frontend/src/lib/host.ts b/src/taskclf/ui/frontend/src/lib/host.ts index e0580d1..bf8a9b0 100644 --- a/src/taskclf/ui/frontend/src/lib/host.ts +++ b/src/taskclf/ui/frontend/src/lib/host.ts @@ -39,35 +39,41 @@ export type Host = { declare global { interface Window { - electronHost?: { - invoke(command: HostCommand): Promise; - }; - pywebview?: { - api?: { - label_grid_show(): Promise; - label_grid_hide(): Promise; - label_grid_toggle(): Promise; - label_grid_cancel_hide(): Promise; - show_transition_notification(prompt: PromptLabelEvent): Promise; - window_hide(): Promise; - dashboard_toggle(): Promise; - state_panel_toggle(): Promise; - state_panel_show(): Promise; - state_panel_hide(): Promise; - state_panel_cancel_hide(): Promise; - frontend_debug_log(message: string): Promise; - frontend_error_log(message: string): Promise; - }; - }; + electronHost: + | { + invoke(command: HostCommand): Promise; + } + | undefined; + pywebview: + | { + api: + | { + label_grid_show(): Promise; + label_grid_hide(): Promise; + label_grid_toggle(): Promise; + label_grid_cancel_hide(): Promise; + show_transition_notification(prompt: PromptLabelEvent): Promise; + window_hide(): Promise; + dashboard_toggle(): Promise; + state_panel_toggle(): Promise; + state_panel_show(): Promise; + state_panel_hide(): Promise; + state_panel_cancel_hide(): Promise; + frontend_debug_log(message: string): Promise; + frontend_error_log(message: string): Promise; + } + | undefined; + } + | undefined; } } function electron_api_ref() { - return window.electronHost ?? null; + return window.electronHost ?? undefined; } function pywebview_api_ref() { - return window.pywebview?.api ?? null; + return window.pywebview?.api ?? undefined; } /** @@ -78,10 +84,10 @@ function pywebview_api_ref() { */ class AdaptiveHost implements Host { get kind(): HostKind { - if (electron_api_ref() !== null) { + if (electron_api_ref() !== undefined) { return "electron"; } - if (pywebview_api_ref() !== null) { + if (pywebview_api_ref() !== undefined) { return "pywebview"; } return "browser"; diff --git a/src/taskclf/ui/frontend/src/lib/labelTimeline.ts b/src/taskclf/ui/frontend/src/lib/labelTimeline.ts index 234858e..9ef0ec9 100644 --- a/src/taskclf/ui/frontend/src/lib/labelTimeline.ts +++ b/src/taskclf/ui/frontend/src/lib/labelTimeline.ts @@ -4,7 +4,7 @@ export type LabelEntry = { label: string; start_ts: string; end_ts: string; - extend_forward?: boolean; + extend_forward: boolean | undefined; }; export type OpenEndedLabelLike = Pick< @@ -13,7 +13,7 @@ export type OpenEndedLabelLike = Pick< >; export type TimelineSegment = { - label: string | null; + label: string | undefined; start_ms: number; end_ms: number; fraction: number; @@ -30,7 +30,7 @@ export type LabelItem = { label: string; start_ts: string; end_ts: string; - open_ended?: boolean; + open_ended: boolean | undefined; }; export type TimelineItem = GapItem | LabelItem; @@ -51,7 +51,7 @@ export function day_timeline_build( if (!entries.length) { const seg: TimelineSegment = { - label: null, + label: undefined, start_ms: day_start, end_ms: day_end, fraction: 1, @@ -83,7 +83,7 @@ export function day_timeline_build( if (s > cursor) { segments.push({ - label: null, + label: undefined, start_ms: cursor, end_ms: s, fraction: (s - cursor) / span_ms, @@ -118,7 +118,7 @@ export function day_timeline_build( if (cursor < day_end) { segments.push({ - label: null, + label: undefined, start_ms: cursor, end_ms: day_end, fraction: (day_end - cursor) / span_ms, diff --git a/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.test.ts b/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.test.ts index 82c9cca..a1938c9 100644 --- a/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.test.ts +++ b/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.test.ts @@ -31,7 +31,7 @@ function selection_base(overrides: Partial = {}): TimeSelection { return { selected_minutes: 5, fill_from_last: false, - last_label_end_ts: null, + last_label_end_ts: undefined, extend_fwd: true, ...overrides, }; @@ -47,7 +47,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ selected_minutes: 5 }), now, ); - expect(result).toBeNull(); + expect(result).toBeUndefined(); }); it("preserves conflicts that still overlap after time change", () => { @@ -59,7 +59,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ selected_minutes: 15 }), now, ); - expect(result).not.toBeNull(); + expect(result).not.toBeUndefined(); expect(result?.conflicts).toHaveLength(1); expect(result?.conflicts[0].label).toBe("Communicate"); }); @@ -78,7 +78,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ selected_minutes: 5 }), now, ); - expect(result).not.toBeNull(); + expect(result).not.toBeUndefined(); expect(result?.conflicts).toHaveLength(1); expect(result?.conflicts[0].label).toBe("Review"); }); @@ -93,7 +93,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ selected_minutes: 10 }), now, ); - if (result === null) { + if (result === undefined) { throw new Error("expected non-null result"); } expect(new Date(result.start).getUTCMinutes()).toBe(20); @@ -110,7 +110,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ fill_from_last: true, last_label_end_ts: iso(10, 45) }), now, ); - if (result === null) { + if (result === undefined) { throw new Error("expected non-null result"); } expect(new Date(result.start).getUTCMinutes()).toBe(45); @@ -126,7 +126,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ fill_from_last: true, last_label_end_ts: iso(10, 0) }), now, ); - expect(result).toBeNull(); + expect(result).toBeUndefined(); }); it("forces extend_forward when selected_minutes is 0", () => { @@ -142,7 +142,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ selected_minutes: 0, extend_fwd: false }), now, ); - expect(result).not.toBeNull(); + expect(result).not.toBeUndefined(); expect(result?.extend_forward).toBe(true); }); @@ -156,7 +156,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ selected_minutes: 5, extend_fwd: false }), now, ); - expect(result).not.toBeNull(); + expect(result).not.toBeUndefined(); expect(result?.extend_forward).toBe(false); }); @@ -172,7 +172,7 @@ describe("label_overwrite_pending_upd_get", () => { selection_base({ selected_minutes: 5 }), now, ); - expect(result).not.toBeNull(); + expect(result).not.toBeUndefined(); expect(result?.label).toBe("Communicate"); expect(result?.confidence).toBe(0.8); }); diff --git a/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.ts b/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.ts index b0968b7..c2d3868 100644 --- a/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.ts +++ b/src/taskclf/ui/frontend/src/lib/label_overwrite_pending_upd_get.ts @@ -4,13 +4,13 @@ import { iso_date_parse } from "./date"; export type TimeSelection = { selected_minutes: number; fill_from_last: boolean; - last_label_end_ts: string | null; + last_label_end_ts: string | undefined; extend_fwd: boolean; }; /** * Recalculate an overwrite-pending state after the user changes the time - * picker. Returns the updated pending, or `null` if no conflicts remain. + * picker. Returns the updated pending, or `undefined` if no conflicts remain. * * `now` is the current wall-clock time — callers should pass `new Date()`. */ @@ -18,7 +18,7 @@ export function label_overwrite_pending_upd_get( pending: OverwritePending, sel: TimeSelection, now: Date, -): OverwritePending | null { +): OverwritePending | undefined { let start: Date; if (sel.fill_from_last && sel.last_label_end_ts) { start = iso_date_parse(sel.last_label_end_ts); @@ -37,7 +37,7 @@ export function label_overwrite_pending_upd_get( }); if (remaining.length === 0) { - return null; + return undefined; } return { diff --git a/src/taskclf/ui/frontend/src/lib/log.ts b/src/taskclf/ui/frontend/src/lib/log.ts index 2f27e94..25c46d0 100644 --- a/src/taskclf/ui/frontend/src/lib/log.ts +++ b/src/taskclf/ui/frontend/src/lib/log.ts @@ -4,7 +4,7 @@ const FRONTEND_LOG_MAX_LEN = 1000; function debug_enabled(): boolean { const vite_meta = import.meta as ImportMeta & { - env?: { DEV?: boolean }; + env: { DEV: boolean | undefined } | undefined; }; return vite_meta.env?.DEV === true; } diff --git a/src/taskclf/ui/frontend/src/lib/notifications.test.ts b/src/taskclf/ui/frontend/src/lib/notifications.test.ts index 8dfc7f1..7fd4fc6 100644 --- a/src/taskclf/ui/frontend/src/lib/notifications.test.ts +++ b/src/taskclf/ui/frontend/src/lib/notifications.test.ts @@ -61,10 +61,10 @@ describe("transition_notification_show", () => { const notification_close = vi.fn(); const notification_ctor = vi.fn(function NotificationMock(this: { close: typeof notification_close; - onclick: (() => void) | null; + onclick: (() => void) | undefined; }) { this.close = notification_close; - this.onclick = null; + this.onclick = undefined; }); Object.assign(notification_ctor, { @@ -87,7 +87,7 @@ describe("transition_notification_show", () => { renotify: true, requireInteraction: true, }); - expect(notification).not.toBeNull(); + expect(notification).not.toBeUndefined(); notification?.onclick?.(new MouseEvent("click")); @@ -109,7 +109,7 @@ describe("transition_notification_show", () => { const { transition_notification_show } = await import("./notifications"); - expect(transition_notification_show(prompt, vi.fn())).toBeNull(); + expect(transition_notification_show(prompt, vi.fn())).toBeUndefined(); expect(notification_ctor).not.toHaveBeenCalled(); }); @@ -118,8 +118,8 @@ describe("transition_notification_show", () => { const no_suggestion_prompt: PromptLabelEvent = { ...prompt, - suggested_label: null, - suggestion_text: null, + suggested_label: undefined, + suggestion_text: undefined, }; const notification_ctor = vi.fn(function NotificationMock() {}); Object.assign(notification_ctor, { diff --git a/src/taskclf/ui/frontend/src/lib/notifications.ts b/src/taskclf/ui/frontend/src/lib/notifications.ts index f088fc8..d53687e 100644 --- a/src/taskclf/ui/frontend/src/lib/notifications.ts +++ b/src/taskclf/ui/frontend/src/lib/notifications.ts @@ -5,8 +5,8 @@ import type { PromptLabelEvent } from "./ws"; // TypeScript's lib.dom.d.ts. Extend until upstream adds it. // https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification#renotify type NotificationOptionsExtended = NotificationOptions & { - renotify?: boolean; - requireInteraction?: boolean; + renotify: boolean | undefined; + requireInteraction: boolean | undefined; }; let permission_granted = false; @@ -75,9 +75,9 @@ export async function notification_permission_ensure(): Promise { export function transition_notification_show( prompt: PromptLabelEvent, on_click: () => void, -): Notification | null { +): Notification | undefined { if (!permission_granted || !("Notification" in window)) { - return null; + return undefined; } const range = notification_range_format(prompt); diff --git a/src/taskclf/ui/frontend/src/lib/nullish.ts b/src/taskclf/ui/frontend/src/lib/nullish.ts new file mode 100644 index 0000000..b51d7fb --- /dev/null +++ b/src/taskclf/ui/frontend/src/lib/nullish.ts @@ -0,0 +1,24 @@ +/** + * Normalize wire-format JSON (`null`) into app-layer optional values (`undefined`). + * Use at fetch / WebSocket parse boundaries only. + */ +export function null_to_undefined(value: T): T { + if (value === null) { + return undefined as T; + } + if (Array.isArray(value)) { + return value.map((item) => null_to_undefined(item)) as T; + } + if (typeof value === "object") { + const record = value as Record; + const out: Record = {}; + for (const key of Object.keys(record)) { + const v = record[key]; + if (v !== null) { + out[key] = null_to_undefined(v); + } + } + return out as T; + } + return value; +} diff --git a/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.test.ts b/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.test.ts index 35c0652..55354b9 100644 --- a/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.test.ts +++ b/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.test.ts @@ -11,7 +11,9 @@ describe("overwrite_pending_from_api_error", () => { }; it("returns null when the message has no JSON", () => { - expect(overwrite_pending_from_api_error(new Error("network"), params)).toBeNull(); + expect( + overwrite_pending_from_api_error(new Error("network"), params), + ).toBeUndefined(); }); it("builds pending from structured detail.conflicting_spans", () => { @@ -29,7 +31,7 @@ describe("overwrite_pending_from_api_error", () => { }; const err = new Error(`409: ${JSON.stringify(body)}`); const p = overwrite_pending_from_api_error(err, params); - expect(p).not.toBeNull(); + expect(p).not.toBeUndefined(); expect(p?.conflicts).toHaveLength(1); expect(p?.conflicts[0].label).toBe("Build"); expect(p?.label).toBe("Write"); diff --git a/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.ts b/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.ts index 71ceb46..aa40d1c 100644 --- a/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.ts +++ b/src/taskclf/ui/frontend/src/lib/overwrite_pending_from_api_error.ts @@ -12,11 +12,11 @@ export type OverwritePendingParams = { export function overwrite_pending_from_api_error( err: unknown, params: OverwritePendingParams, -): OverwritePending | null { +): OverwritePending | undefined { const msg = err instanceof Error ? err.message : ""; const json_match = msg.match(/\{[\s\S]*\}/); if (!json_match) { - return null; + return undefined; } try { const parsed = JSON.parse(json_match[0]); @@ -35,7 +35,7 @@ export function overwrite_pending_from_api_error( }); } if (spans.length === 0) { - return null; + return undefined; } return { label: params.label, @@ -46,6 +46,6 @@ export function overwrite_pending_from_api_error( extend_forward: params.extend_forward, }; } catch { - return null; + return undefined; } } diff --git a/src/taskclf/ui/frontend/src/lib/transitionPromptNotifications.ts b/src/taskclf/ui/frontend/src/lib/transitionPromptNotifications.ts index 73be93b..444dd96 100644 --- a/src/taskclf/ui/frontend/src/lib/transitionPromptNotifications.ts +++ b/src/taskclf/ui/frontend/src/lib/transitionPromptNotifications.ts @@ -45,7 +45,7 @@ function transition_prompt_clone(prompt: PromptLabelEvent): PromptLabelEvent { } export function transition_prompt_notifications_bind( - prompt: Accessor, + prompt: Accessor, on_open_label_grid: () => void, ): void { onMount(() => { diff --git a/src/taskclf/ui/frontend/src/lib/ws.test.ts b/src/taskclf/ui/frontend/src/lib/ws.test.ts index d1b4f5f..8f60807 100644 --- a/src/taskclf/ui/frontend/src/lib/ws.test.ts +++ b/src/taskclf/ui/frontend/src/lib/ws.test.ts @@ -5,10 +5,12 @@ import { suggestion_banner_ttl_ms_from_seconds, ws_store_new } from "./ws"; describe("suggestion_banner_ttl_ms_from_seconds", () => { it("returns null when disabled or invalid", () => { - expect(suggestion_banner_ttl_ms_from_seconds(0)).toBeNull(); - expect(suggestion_banner_ttl_ms_from_seconds(-1)).toBeNull(); - expect(suggestion_banner_ttl_ms_from_seconds(Number.NaN)).toBeNull(); - expect(suggestion_banner_ttl_ms_from_seconds(Number.POSITIVE_INFINITY)).toBeNull(); + expect(suggestion_banner_ttl_ms_from_seconds(0)).toBeUndefined(); + expect(suggestion_banner_ttl_ms_from_seconds(-1)).toBeUndefined(); + expect(suggestion_banner_ttl_ms_from_seconds(Number.NaN)).toBeUndefined(); + expect( + suggestion_banner_ttl_ms_from_seconds(Number.POSITIVE_INFINITY), + ).toBeUndefined(); }); it("returns milliseconds for positive seconds", () => { @@ -27,10 +29,10 @@ class MockWebSocket { readonly url: string; readyState = MockWebSocket.CONNECTING; - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onclose: ((event: CloseEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; + onopen: ((event: Event) => void) | undefined = undefined; + onmessage: ((event: MessageEvent) => void) | undefined = undefined; + onclose: ((event: CloseEvent) => void) | undefined = undefined; + onerror: ((event: Event) => void) | undefined = undefined; constructor(url: string) { this.url = url; @@ -98,7 +100,7 @@ describe("ws_store_new badge display override", () => { let store!: ReturnType; const mounted = render(() => { store = ws_store_new(); - return null; + return undefined; }); unmounts.push(mounted.unmount); return store; @@ -151,7 +153,7 @@ describe("ws_store_new badge display override", () => { reason: "skipped", }); await waitFor(() => { - expect(store.active_suggestion()).toBeNull(); + expect(store.active_suggestion()).toBeUndefined(); }); expect(store.badge_display_override()).toEqual({ enabled: true, @@ -168,7 +170,7 @@ describe("ws_store_new badge display override", () => { await waitFor(() => { expect(store.badge_display_override()).toEqual({ enabled: false, - label: null, + label: undefined, }); }); }); @@ -207,7 +209,7 @@ describe("ws_store_new badge display override", () => { reason: "label_saved", }); await waitFor(() => { - expect(store.active_suggestion()).toBeNull(); + expect(store.active_suggestion()).toBeUndefined(); }); expect(store.badge_display_override()).toEqual({ enabled: true, @@ -223,7 +225,7 @@ describe("ws_store_new badge display override", () => { await waitFor(() => { expect(store.badge_display_override()).toEqual({ enabled: false, - label: null, + label: undefined, }); }); }); diff --git a/src/taskclf/ui/frontend/src/lib/ws.ts b/src/taskclf/ui/frontend/src/lib/ws.ts index 6b4b058..26fd1f8 100644 --- a/src/taskclf/ui/frontend/src/lib/ws.ts +++ b/src/taskclf/ui/frontend/src/lib/ws.ts @@ -2,18 +2,21 @@ import { onCleanup, onMount } from "solid-js"; import { createStore, produce, reconcile } from "solid-js/store"; import { type ActivityProviderStatus, user_config_get } from "./api"; +import { null_to_undefined } from "./nullish"; /** - * Maps persisted config seconds to a timer duration; `null` means no auto-dismiss. + * Maps persisted config seconds to a timer duration; `undefined` means no auto-dismiss. * Exported for unit tests. */ -export function suggestion_banner_ttl_ms_from_seconds(seconds: number): number | null { +export function suggestion_banner_ttl_ms_from_seconds( + seconds: number, +): number | undefined { if (!Number.isFinite(seconds) || seconds <= 0) { - return null; + return undefined; } const ms = Math.floor(seconds) * 1000; if (!Number.isFinite(ms) || ms <= 0) { - return null; + return undefined; } return Math.min(ms, Number.MAX_SAFE_INTEGER); } @@ -24,13 +27,13 @@ export type Prediction = { confidence: number; ts: string; mapped_label: string; - current_app?: string; - provenance?: "manual" | "model"; + current_app: string | undefined; + provenance: "manual" | "model" | undefined; }; export type LabelSuggestion = { type: "suggest_label"; - suggestion_id?: string; + suggestion_id: string | undefined; reason: string; old_label: string; suggested: string; @@ -67,8 +70,8 @@ export type StatusEvent = { type: "status"; state: "idle" | "collecting" | "predicting" | "paused"; current_app: string; - current_app_since: string | null; - candidate_app: string | null; + current_app_since: string | undefined; + candidate_app: string | undefined; candidate_duration_s: number; transition_threshold_s: number; poll_seconds: number; @@ -77,7 +80,7 @@ export type StatusEvent = { uptime_s: number; activity_provider: ActivityProviderStatus; aw_connected: boolean; - aw_bucket_id: string | null; + aw_bucket_id: string | undefined; aw_host: string; last_event_count: number; last_app_counts: Record; @@ -86,8 +89,8 @@ const StatusEventDefault: StatusEvent = { type: "status", state: "idle", current_app: "unknown", - current_app_since: null, - candidate_app: null, + current_app_since: undefined, + candidate_app: undefined, candidate_duration_s: 0, transition_threshold_s: 0, poll_seconds: 0, @@ -100,7 +103,7 @@ const StatusEventDefault: StatusEvent = { state: "checking", summary_available: false, endpoint: "http://localhost:5600", - source_id: null, + source_id: undefined, last_sample_count: 0, last_sample_breakdown: {}, setup_title: "Activity source unavailable", @@ -114,7 +117,7 @@ const StatusEventDefault: StatusEvent = { help_url: "https://activitywatch.net/", }, aw_connected: false, - aw_bucket_id: null, + aw_bucket_id: undefined, aw_host: "http://localhost:5600", last_event_count: 0, last_app_counts: {}, @@ -131,12 +134,12 @@ export type TransitionInfo = { export type TrayState = { type: "tray_state"; model_loaded: boolean; - model_dir: string | null; - model_schema_hash: string | null; - suggested_label: string | null; - suggested_confidence: number | null; + model_dir: string | undefined; + model_schema_hash: string | undefined; + suggested_label: string | undefined; + suggested_confidence: number | undefined; transition_count: number; - last_transition: TransitionInfo | null; + last_transition: TransitionInfo | undefined; labels_saved_count: number; data_dir: string; ui_port: number; @@ -146,12 +149,12 @@ export type TrayState = { const TrayStateDefault: TrayState = { type: "tray_state", model_loaded: false, - model_dir: null, - model_schema_hash: null, - suggested_label: null, - suggested_confidence: null, + model_dir: undefined, + model_schema_hash: undefined, + suggested_label: undefined, + suggested_confidence: undefined, transition_count: 0, - last_transition: null, + last_transition: undefined, labels_saved_count: 0, data_dir: "~/.taskclf", ui_port: 0, @@ -170,14 +173,14 @@ export type PromptLabelEvent = { block_start: string; block_end: string; duration_min: number; - suggested_label: string | null; - suggestion_text: string | null; + suggested_label: string | undefined; + suggestion_text: string | undefined; }; export type SuggestionClearedEvent = { type: "suggestion_cleared"; reason: string; - suggestion_id?: string; + suggestion_id: string | undefined; }; export type SuggestionClearReason = "label_saved" | "skipped" | string; @@ -226,15 +229,17 @@ export type TrainProgressEvent = { type: "train_progress"; job_id: string; step: string; - progress_pct: number | null; - message: string | null; + progress_pct: number | undefined; + message: string | undefined; }; export type TrainCompleteEvent = { type: "train_complete"; job_id: string; - metrics: { macro_f1?: number; weighted_f1?: number } | null; - model_dir: string | null; + metrics: + | { macro_f1: number | undefined; weighted_f1: number | undefined } + | undefined; + model_dir: string | undefined; }; export type TrainFailedEvent = { @@ -264,14 +269,16 @@ export type WSEvent = export type ConnectionStatus = "connecting" | "connected" | "disconnected"; export type TrainState = { - job_id: string | null; + job_id: string | undefined; status: "idle" | "running" | "complete" | "failed"; - step: string | null; - progress_pct: number | null; - message: string | null; - error: string | null; - metrics: { macro_f1?: number; weighted_f1?: number } | null; - model_dir: string | null; + step: string | undefined; + progress_pct: number | undefined; + message: string | undefined; + error: string | undefined; + metrics: + | { macro_f1: number | undefined; weighted_f1: number | undefined } + | undefined; + model_dir: string | undefined; }; export type WSStats = { @@ -280,26 +287,26 @@ export type WSStats = { prediction_count: number; tray_state_count: number; suggestion_count: number; - last_message_at: string | null; + last_message_at: string | undefined; reconnect_count: number; - connected_since: string | null; + connected_since: string | undefined; }; export type BadgeDisplayOverride = { enabled: boolean; - label: string | null; + label: string | undefined; }; export type WebSocketStore = { latest_status: StatusEvent; - latest_prediction: Prediction | null; + latest_prediction: Prediction | undefined; latest_tray_state: TrayState; - active_suggestion: LabelSuggestion | null; + active_suggestion: LabelSuggestion | undefined; pending_suggestions: LabelSuggestion[]; badge_display_override: BadgeDisplayOverride; - badge_display_restore_label: string | null; - latest_prompt: PromptLabelEvent | null; - live_status: LiveStatusEvent | null; + badge_display_restore_label: string | undefined; + latest_prompt: PromptLabelEvent | undefined; + live_status: LiveStatusEvent | undefined; label_grid_requested: number; label_change_count: number; train_state: TrainState; @@ -310,28 +317,28 @@ export type WebSocketStore = { export function ws_store_new() { const [store, setStore] = createStore({ latest_status: StatusEventDefault, - latest_prediction: null, + latest_prediction: undefined, latest_tray_state: TrayStateDefault, - active_suggestion: null, + active_suggestion: undefined, pending_suggestions: [], badge_display_override: { enabled: false, - label: null, + label: undefined, }, - badge_display_restore_label: null, - latest_prompt: null, - live_status: null, + badge_display_restore_label: undefined, + latest_prompt: undefined, + live_status: undefined, label_grid_requested: 0, label_change_count: 0, train_state: { - job_id: null, + job_id: undefined, status: "idle", - step: null, - progress_pct: null, - message: null, - error: null, - metrics: null, - model_dir: null, + step: undefined, + progress_pct: undefined, + message: undefined, + error: undefined, + metrics: undefined, + model_dir: undefined, }, connection_status: "connecting", ws_stats: { @@ -340,17 +347,17 @@ export function ws_store_new() { prediction_count: 0, tray_state_count: 0, suggestion_count: 0, - last_message_at: null, + last_message_at: undefined, reconnect_count: 0, - connected_since: null, + connected_since: undefined, }, }); let suggestion_ttl_seconds = 0; - let ws: WebSocket | null = null; - let reconnect_timer: ReturnType | null = null; - let suggestion_timer: ReturnType | null = null; + let ws: WebSocket | undefined; + let reconnect_timer: ReturnType | undefined; + let suggestion_timer: ReturnType | undefined; let retry_delay = 1000; function badge_explicit_label_get( @@ -360,7 +367,7 @@ export function ws_store_new() { if (pred) { return pred.mapped_label || pred.label; } - return state.live_status?.label ?? null; + return state.live_status?.label ?? undefined; } function badge_display_override_clear_if_superseded() { @@ -370,8 +377,8 @@ export function ws_store_new() { return; } state.badge_display_override.enabled = false; - state.badge_display_override.label = null; - state.badge_display_restore_label = null; + state.badge_display_override.label = undefined; + state.badge_display_restore_label = undefined; }), ); } @@ -383,7 +390,7 @@ export function ws_store_new() { } if ( !state.badge_display_override.enabled - || state.badge_display_restore_label == null + || state.badge_display_restore_label === undefined ) { state.badge_display_restore_label = badge_explicit_label_get(state); } @@ -393,18 +400,18 @@ export function ws_store_new() { function suggestion_active_set_in_state( state: WebSocketStore, - preferred_key?: string | null, + preferred_key: string | undefined = undefined, ) { const current_key = state.active_suggestion ? label_suggestion_key(state.active_suggestion) - : null; + : undefined; const key = preferred_key ?? current_key; const next = (key ? state.pending_suggestions.find((item) => label_suggestion_key(item) === key) - : null) + : undefined) ?? state.pending_suggestions[0] - ?? null; + ?? undefined; state.active_suggestion = next; suggestion_badge_override_apply_for_active(state); @@ -416,7 +423,7 @@ export function ws_store_new() { const suggestion_key = label_suggestion_key(suggestion); const active_key = state.active_suggestion ? label_suggestion_key(state.active_suggestion) - : null; + : undefined; const existing_index = state.pending_suggestions.findIndex( (item) => label_suggestion_key(item) === suggestion_key, ); @@ -442,19 +449,21 @@ export function ws_store_new() { } function suggestion_queue_remove( - reason?: SuggestionClearReason, - suggestion_id?: string | null, + reason: SuggestionClearReason | undefined = undefined, + suggestion_id: string | undefined = undefined, ) { setStore( produce((state) => { const active_key = state.active_suggestion ? label_suggestion_key(state.active_suggestion) - : null; + : undefined; const clear_key = suggestion_id ?? active_key; const cleared_active = - active_key != null && clear_key != null && active_key === clear_key; + active_key !== undefined + && clear_key !== undefined + && active_key === clear_key; - if (clear_key != null) { + if (clear_key !== undefined) { state.pending_suggestions = state.pending_suggestions.filter( (item) => label_suggestion_key(item) !== clear_key, ); @@ -463,19 +472,19 @@ export function ws_store_new() { } if (cleared_active) { - state.active_suggestion = null; + state.active_suggestion = undefined; } suggestion_active_set_in_state(state); if (state.active_suggestion) { return; } - if (!state.badge_display_override.enabled || reason == null) { + if (!state.badge_display_override.enabled || reason === undefined) { return; } if (reason === "skipped") { state.badge_display_override.label = state.badge_display_restore_label; } - state.badge_display_restore_label = null; + state.badge_display_restore_label = undefined; }), ); } @@ -502,7 +511,7 @@ export function ws_store_new() { || !Number.isFinite(stop_ms) || pred_ms <= stop_ms ) { - state.latest_prediction = null; + state.latest_prediction = undefined; } }), ); @@ -511,18 +520,18 @@ export function ws_store_new() { function suggestion_timer_clear() { if (suggestion_timer) { clearTimeout(suggestion_timer); - suggestion_timer = null; + suggestion_timer = undefined; } } function suggestion_timer_start() { suggestion_timer_clear(); const ttl_ms = suggestion_banner_ttl_ms_from_seconds(suggestion_ttl_seconds); - if (ttl_ms == null) { + if (ttl_ms === undefined) { return; } suggestion_timer = setTimeout(() => { - suggestion_timer = null; + suggestion_timer = undefined; suggestion_queue_remove(); }, ttl_ms); } @@ -537,7 +546,10 @@ export function ws_store_new() { } } - function ws_stats_bump(now: string, extra?: (s: WSStats) => void) { + function ws_stats_bump( + now: string, + extra: ((s: WSStats) => void) | undefined = undefined, + ) { setStore( "ws_stats", produce((s) => { @@ -554,12 +566,15 @@ export function ws_store_new() { if (!resp.ok) { return; } - const snap: Record = await resp.json(); + const snap = null_to_undefined>(await resp.json()); if (snap.status) { setStore("latest_status", reconcile(snap.status as StatusEvent)); } if (snap.prediction) { - setStore("latest_prediction", reconcile(snap.prediction as Prediction | null)); + setStore( + "latest_prediction", + reconcile(snap.prediction as Prediction | undefined), + ); badge_display_override_clear_if_superseded(); } if (snap.live_status) { @@ -612,7 +627,7 @@ export function ws_store_new() { ws.onmessage = (event) => { try { - const data: WSEvent = JSON.parse(event.data); + const data = null_to_undefined(JSON.parse(event.data)); const now = new Date().toISOString(); switch (data.type) { case "status": @@ -622,7 +637,7 @@ export function ws_store_new() { }); break; case "prediction": - setStore("latest_prediction", reconcile(data as Prediction | null)); + setStore("latest_prediction", reconcile(data as Prediction | undefined)); badge_display_override_clear_if_superseded(); ws_stats_bump(now, (s) => { s.prediction_count++; @@ -676,8 +691,9 @@ export function ws_store_new() { confidence: data.confidence, ts: data.ts, mapped_label: data.label, + current_app: undefined, provenance: "manual", - } as Prediction | null), + } as Prediction | undefined), ); badge_display_override_clear_if_superseded(); ws_stats_bump(now, (s) => { @@ -713,8 +729,8 @@ export function ws_store_new() { t.status = "complete"; t.step = "done"; t.progress_pct = 100; - t.message = null; - t.error = null; + t.message = undefined; + t.error = undefined; t.metrics = data.metrics; t.model_dir = data.model_dir; }), @@ -727,9 +743,9 @@ export function ws_store_new() { produce((t) => { t.job_id = data.job_id; t.status = "failed"; - t.step = null; - t.progress_pct = null; - t.message = null; + t.step = undefined; + t.progress_pct = undefined; + t.message = undefined; t.error = data.error; }), ); @@ -743,7 +759,7 @@ export function ws_store_new() { ws.onclose = () => { setStore("connection_status", "disconnected"); - setStore("ws_stats", "connected_since", null); + setStore("ws_stats", "connected_since", undefined); reconnect_schedule(); }; @@ -762,7 +778,7 @@ export function ws_store_new() { } const jitter = retry_delay * (0.5 + Math.random() * 0.5); reconnect_timer = setTimeout(() => { - reconnect_timer = null; + reconnect_timer = undefined; retry_delay = Math.min(retry_delay * 2, 30_000); setStore("ws_stats", "reconnect_count", (c) => c + 1); ws_connection_open(); @@ -773,7 +789,7 @@ export function ws_store_new() { retry_delay = 1000; if (reconnect_timer) { clearTimeout(reconnect_timer); - reconnect_timer = null; + reconnect_timer = undefined; } ws_connection_open(); } @@ -786,19 +802,19 @@ export function ws_store_new() { retry_delay = 1000; if (reconnect_timer) { clearTimeout(reconnect_timer); - reconnect_timer = null; + reconnect_timer = undefined; } ws_connection_open(); } } function suggestion_dismiss( - reason?: SuggestionClearReason, - suggestion?: LabelSuggestion | null, + reason: SuggestionClearReason | undefined = undefined, + suggestion: LabelSuggestion | undefined = undefined, ) { suggestion_queue_remove( reason, - suggestion ? label_suggestion_key(suggestion) : null, + suggestion ? label_suggestion_key(suggestion) : undefined, ); suggestion_timer_clear(); } diff --git a/src/taskclf/ui/frontend/src/test/ws_store_stub.ts b/src/taskclf/ui/frontend/src/test/ws_store_stub.ts index e49c881..172d85c 100644 --- a/src/taskclf/ui/frontend/src/test/ws_store_stub.ts +++ b/src/taskclf/ui/frontend/src/test/ws_store_stub.ts @@ -7,8 +7,8 @@ export function ws_store_stub() { type: "status" as const, state: "idle" as const, current_app: "unknown", - current_app_since: null, - candidate_app: null, + current_app_since: undefined, + candidate_app: undefined, candidate_duration_s: 0, transition_threshold_s: 0, poll_seconds: 0, @@ -21,7 +21,7 @@ export function ws_store_stub() { state: "checking" as const, summary_available: false, endpoint: "http://localhost:5600", - source_id: null, + source_id: undefined, last_sample_count: 0, last_sample_breakdown: {}, setup_title: "Activity source unavailable", @@ -35,35 +35,35 @@ export function ws_store_stub() { help_url: "https://activitywatch.net/", }, aw_connected: false, - aw_bucket_id: null, + aw_bucket_id: undefined, aw_host: "http://localhost:5600", last_event_count: 0, last_app_counts: {}, }), - latest_prediction: () => null, + latest_prediction: () => undefined, badge_display_override: () => ({ enabled: false, - label: null, + label: undefined, }), latest_tray_state: () => ({ type: "tray_state" as const, model_loaded: false, - model_dir: null, - model_schema_hash: null, - suggested_label: null, - suggested_confidence: null, + model_dir: undefined, + model_schema_hash: undefined, + suggested_label: undefined, + suggested_confidence: undefined, transition_count: 0, - last_transition: null, + last_transition: undefined, labels_saved_count: 0, data_dir: "~/.taskclf", ui_port: 0, dev_mode: false, paused: false, }), - active_suggestion: () => null, + active_suggestion: () => undefined, pending_suggestions: () => [], - latest_prompt: () => null, - live_status: () => null, + latest_prompt: () => undefined, + live_status: () => undefined, label_grid_requested: () => 0, label_change_count: () => 0, connection_status: () => "connected" as const, @@ -73,19 +73,19 @@ export function ws_store_stub() { prediction_count: 0, tray_state_count: 0, suggestion_count: 0, - last_message_at: null, + last_message_at: undefined, reconnect_count: 0, - connected_since: null, + connected_since: undefined, }), train_state: () => ({ - job_id: null, + job_id: undefined, status: "idle" as const, - step: null, - progress_pct: null, - message: null, - error: null, - metrics: null, - model_dir: null, + step: undefined, + progress_pct: undefined, + message: undefined, + error: undefined, + metrics: undefined, + model_dir: undefined, }), suggestion_dismiss: vi.fn(), suggestion_select: vi.fn(),