From d7d14957537aba68a7ff9d9c718b222b7274be7c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 21 Oct 2026 14:17:02 -0400 Subject: [PATCH] fix(types): clear web/src type and lint debt with real types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsc --noEmit and oxlint web/src are now both clean; vp check passes. - htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx declaration (TS2717) — removed; bookPicker/collections lose their (window as any) escapes now that Window.htmx is properly typed. - tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists (escapeHtml) and for..of over querySelectorAll results is legal. Type-checking only — vite emits at esnext regardless. - custom-section-builder: selectedBooks holds identity fields only; typed as Pick instead of forcing partial API shapes. The API-critical BookInfo contract is untouched. - toast: real bug found by the type system — setup.ts passes 'warning' but getToastConfig had no entry, so the warning toast threw TypeError at runtime instead of displaying. Added the type + config. Uses bg-yellow-500 (verified present in scanned templates => generated by Tailwind); the one intentional behavior change in this commit. - book-detail: instanceof narrowing replaces an as-cast; unused AnnotationSource import removed. - password_validation: explicit HTMLElement | null for the checklist list (querySelector('ul') infers HTMLUListElement, conflicting with the container-as-list branch). - stats: tooltip parsed value typed via annotation (bar=scalar, line={x,y}); dead getActiveRange() removed (was already tree-shaken out of every bundle). - library-switcher: onSwitch honestly typed void | Promise (series.ts passes a sync navigator). - 20 unused catch bindings converted to optional catch binding. Verification: fresh build is byte-reproducible; every chunk is identical to the pre-fix build except main.js (instanceof narrowing, regex fix, stats refactor), reader.js (destructure), and the shared storage chunk (renamed — carries toast.ts). reader/main diffs audited hunk-by-hunk against the intended edits. --- tsconfig.json | 2 +- web/src/alpine.ts | 7 +++---- web/src/book-detail.ts | 15 +++++++------- web/src/bookPicker.ts | 16 +++++++-------- web/src/collections.ts | 4 ++-- web/src/custom-section-builder.ts | 6 +++++- web/src/device-management.ts | 2 +- web/src/library-switcher.ts | 4 +++- web/src/password_validation.ts | 4 ++-- web/src/reader/reader.ts | 32 +++++++++++++++--------------- web/src/reader/settings-manager.ts | 4 ++-- web/src/shims.d.ts | 13 ++++++++---- web/src/stats.ts | 9 +++------ web/src/toast.ts | 12 ++++++++--- 14 files changed, 70 insertions(+), 60 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index d99dbcf2..c605829e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ES2020", - "lib": ["ES2020", "DOM"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "outDir": "./web/static", "rootDir": "./web/src", "moduleResolution": "bundler", diff --git a/web/src/alpine.ts b/web/src/alpine.ts index 75a73daf..7d6a4602 100644 --- a/web/src/alpine.ts +++ b/web/src/alpine.ts @@ -1,12 +1,11 @@ import Alpine from "alpinejs"; -// Extend Window interface to include Alpine +// Extend Window interface to include Alpine. The `htmx` member is declared +// once in shims.d.ts — redeclaring it here with a different shape is a +// compile error (TS2717) and split-brained typing. declare global { interface Window { Alpine: typeof Alpine; - htmx: { - trigger: (element: HTMLElement | string, event: string) => void; - }; } } diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index e7f0e9d8..8787e162 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -9,7 +9,6 @@ import { triggerDownload, bannerBookTitle, type AnnotationRow, - type AnnotationSource, } from "./annotations-banner"; function getMediaId(): string { @@ -69,8 +68,8 @@ function collectFormData(editorTags: string[]): Record { if (el.type === "number") { const val = parseFloat(el.value); data[el.name] = isNaN(val) ? 0 : val; - } else if (el.type === "checkbox") { - data[el.name] = (el as HTMLInputElement).checked; + } else if (el instanceof HTMLInputElement && el.type === "checkbox") { + data[el.name] = el.checked; } else { data[el.name] = el.value; } @@ -369,7 +368,7 @@ Alpine.data("bookDetail", () => { } await this.fetchBannerAnnotations(); showToast("Note saved", "success"); - } catch (e) { + } catch { showToast("Could not save the note", "error"); } }, @@ -386,7 +385,7 @@ Alpine.data("bookDetail", () => { // Tombstoned server-side: restorable from the Deleted modal. showToast("Note deleted — restorable from Deleted", "success"); await this.fetchBannerAnnotations(); - } catch (_e) { + } catch { showToast("Could not delete the note", "error"); } }, @@ -403,7 +402,7 @@ Alpine.data("bookDetail", () => { // Tombstoned server-side: restorable from the Deleted modal. showToast("Bookmark deleted — restorable from Deleted", "success"); await this.fetchBannerAnnotations(); - } catch (_e) { + } catch { showToast("Could not delete the bookmark", "error"); } }, @@ -420,7 +419,7 @@ Alpine.data("bookDetail", () => { // Tombstoned server-side: restorable from the Deleted modal. showToast("Highlight deleted — restorable from Deleted", "success"); await this.fetchBannerAnnotations(); - } catch (_e) { + } catch { showToast("Could not delete the highlight", "error"); } }, @@ -455,7 +454,7 @@ Alpine.data("bookDetail", () => { ); } showToast("Annotations exported", "success"); - } catch (_e) { + } catch { showToast("Export failed", "error"); } }, diff --git a/web/src/bookPicker.ts b/web/src/bookPicker.ts index 455c200a..eeaf7428 100644 --- a/web/src/bookPicker.ts +++ b/web/src/bookPicker.ts @@ -105,32 +105,30 @@ Alpine.store("bookPicker", { // Provides methods for template to access store Alpine.data("bookPicker", () => ({ get isOpen() { - return (window as any).Alpine?.$store.bookPicker.isOpen; + return window.Alpine?.$store.bookPicker.isOpen; }, get selectedCount() { - return (window as any).Alpine?.$store.bookPicker.selectedCount; + return window.Alpine?.$store.bookPicker.selectedCount; }, toggleBook(bookId: string) { - (window as any).Alpine?.$store.bookPicker.toggleBook(bookId); + window.Alpine?.$store.bookPicker.toggleBook(bookId); }, isSelected(bookId: string): boolean { - return (window as any).Alpine?.$store.bookPicker.isSelected(bookId); + return window.Alpine?.$store.bookPicker.isSelected(bookId); }, clearFilters() { - (window as any).Alpine?.$store.bookPicker.clearFilters(); + window.Alpine?.$store.bookPicker.clearFilters(); }, close() { - (window as any).Alpine?.$store.bookPicker.close(); + window.Alpine?.$store.bookPicker.close(); }, submit() { - (window as any).Alpine?.$store.bookPicker.submit(); + window.Alpine?.$store.bookPicker.submit(); }, })); - -export {}; diff --git a/web/src/collections.ts b/web/src/collections.ts index c617cb21..7375a64d 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -587,8 +587,8 @@ async function moveCollectionBook(this: any, mediaId: string, direction: number) showToast("Failed to save the new order", "error"); // Rendering ownership: the templ partial re-renders the true order. const gridTarget = document.getElementById("books-container"); - if (gridTarget && (window as any).htmx) { - (window as any).htmx.ajax( + if (gridTarget && window.htmx) { + window.htmx.ajax( "GET", `/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`, { target: "#books-container", swap: "innerHTML" }, diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts index e95208b2..e9ff6a5a 100644 --- a/web/src/custom-section-builder.ts +++ b/web/src/custom-section-builder.ts @@ -210,7 +210,11 @@ const FILTER_FIELDS: FilterField[] = [ ]; let ruleCounter = 0; -let selectedBooks: Map = new Map(); +// Books picked in the builder only ever carry identity fields; the API's +// BookInfo conflict/format/badge metadata is attached server-side later. +type SelectedBook = Pick; + +let selectedBooks: Map = new Map(); let customSectionTimeout: number | null = null; function initCustomSectionBuilder(): void { diff --git a/web/src/device-management.ts b/web/src/device-management.ts index a81879ff..5c260e78 100644 --- a/web/src/device-management.ts +++ b/web/src/device-management.ts @@ -191,7 +191,7 @@ async function loadShelfMappings(deviceId: string): Promise { container.innerHTML = '

No shelf mappings configured. Click "Add Mapping" to create one.

'; } - } catch (error) { + } catch { container.innerHTML = '

Failed to load mappings

'; } } diff --git a/web/src/library-switcher.ts b/web/src/library-switcher.ts index 9f4d20a6..4104b53c 100644 --- a/web/src/library-switcher.ts +++ b/web/src/library-switcher.ts @@ -73,7 +73,9 @@ export async function switchWithTransition( } export interface LibrarySwitcherOptions { - onSwitch: (libraryId: string) => Promise; + // Async switchers (SSR navigation awaits nothing, API-backed switchers + // await their fetch) — hence void | Promise. + onSwitch: (libraryId: string) => void | Promise; } export function initLibrarySwitcher(options: LibrarySwitcherOptions): void { diff --git a/web/src/password_validation.ts b/web/src/password_validation.ts index b930841e..8c4bafa5 100644 --- a/web/src/password_validation.ts +++ b/web/src/password_validation.ts @@ -63,7 +63,7 @@ function hasNumber(password: string): boolean { return /[0-9]/.test(password); } function hasSpecialChar(password: string): boolean { - return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); + return /[!@#$%^&*()_+\-=[]{};':"\\|,.<>?]/.test(password); } function passwordsMatch(password: string, confirm: string): boolean { return password.length > 0 && password === confirm; @@ -107,7 +107,7 @@ function requirementRow(id: string, label: string): string { function ensureChecklist(containerId: string, rules: PasswordRules, hasConfirm: boolean): void { const container = document.getElementById(containerId); if (!container) return; - let list = container.querySelector("ul"); + let list: HTMLElement | null = container.querySelector("ul"); if (container.tagName === "UL") list = container; // container IS the list if (!list) { container.insertAdjacentHTML( diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 08a6a804..020520f3 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -187,7 +187,7 @@ async function loadFontBlobUrls(): Promise { const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); return { key: f.url, blobUrl }; - } catch (_e) { + } catch { return { key: f.url, blobUrl: f.url }; } }), @@ -1533,7 +1533,7 @@ document.addEventListener("alpine:init", () => { async adjustFinalize() { const ctx = this.adjustContext; if (!ctx) return; - const { index, doc, range, id } = ctx; + const { index, range, id } = ctx; const text = range.toString().replace(/\s+/g, " ").trim(); this.cancelAdjust(); if (!text) { @@ -2452,7 +2452,7 @@ document.addEventListener("alpine:init", () => { positionLabel: r.position ?? "", })); } - } catch (_e) { + } catch { /* annotations are non-critical; leave lists as-is */ } }, @@ -2523,7 +2523,7 @@ document.addEventListener("alpine:init", () => { } } this.hideSelectionPopover(); - } catch (_e) { + } catch { /* ignore highlight errors */ } }, @@ -2590,7 +2590,7 @@ document.addEventListener("alpine:init", () => { // Explicit saves (the Save button) close the whole popover; color // tweaks made while the note editor is open keep everything open. if (!keepNoteOpen) this.hideSelectionPopover(); - } catch (_e) { + } catch { /* ignore highlight errors */ } }, @@ -2615,7 +2615,7 @@ document.addEventListener("alpine:init", () => { }); } this.hideSelectionPopover(); - } catch (_e) { + } catch { /* ignore highlight errors */ } }, @@ -2839,7 +2839,7 @@ document.addEventListener("alpine:init", () => { }); if (!resp.ok) return; await this.refreshAnnotations(); - } catch (_e) { + } catch { /* ignore note errors */ } }, @@ -2882,7 +2882,7 @@ document.addEventListener("alpine:init", () => { note.positionLabel = row.position ?? note.positionLabel; note.editOpen = false; note.editText = ""; - } catch (_e) { + } catch { /* note errors are non-critical */ } }, @@ -2897,7 +2897,7 @@ document.addEventListener("alpine:init", () => { if (resp.ok || resp.status === 204) { this.noteItems = this.noteItems.filter((n) => n.id !== id); } - } catch (_e) { + } catch { /* ignore note errors */ } }, @@ -2928,7 +2928,7 @@ document.addEventListener("alpine:init", () => { page: typeof page === "number" ? page : 0, percentage: typeof pct === "number" ? pct : 0, }; - } catch (_e) { + } catch { return {}; } }, @@ -3017,7 +3017,7 @@ document.addEventListener("alpine:init", () => { }); this.lastSyncedCfi = cfi || ""; this.lastSyncedFraction = fraction; - } catch (_e) { + } catch { // silent fail — progress save is non-critical } }, @@ -3658,7 +3658,7 @@ document.addEventListener("alpine:init", () => { cfi: r.cfi_position?.String ?? r.cfi_position ?? "", page: r.page_number != null ? (r.page_number?.Int32 ?? r.page_number) : null, })); - } catch (_e) { + } catch { /* leave the existing list on fetch failure */ } }, @@ -3696,7 +3696,7 @@ document.addEventListener("alpine:init", () => { await this.refreshBookmarks(); showToast(`Bookmark added — ${this.progressText || "current position"}`, "success", 2500); } - } catch (_e) { + } catch { /* network failures surface via the toast fetch interceptor */ } }, @@ -3711,7 +3711,7 @@ document.addEventListener("alpine:init", () => { if (resp.ok || resp.status === 204) { this.bookmarkItems = this.bookmarkItems.filter((b) => b.id !== id); } - } catch (_e) { + } catch { /* ignore bookmark errors for now */ } }, @@ -3740,7 +3740,7 @@ document.addEventListener("alpine:init", () => { bookmark.title = title; } bookmark.renameOpen = false; - } catch (_e) { + } catch { bookmark.renameOpen = false; } }, @@ -3905,7 +3905,7 @@ document.addEventListener("alpine:init", () => { const data = await resp.json(); this.readingSpeedPpm = data.pages_per_minute || 0; } - } catch (_e) { + } catch { this.readingSpeedPpm = 0; } }, diff --git a/web/src/reader/settings-manager.ts b/web/src/reader/settings-manager.ts index 911751c1..aa5790fc 100644 --- a/web/src/reader/settings-manager.ts +++ b/web/src/reader/settings-manager.ts @@ -16,7 +16,7 @@ export async function loadSettings(): Promise { const settings = await response.json(); localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)); return settings; - } catch (error) { + } catch { const local = localStorage.getItem(LOCALSTORAGE_KEY); return local ? JSON.parse(local) : getDefaultSettings(); } @@ -39,7 +39,7 @@ export async function saveSettings(settings: Partial): Promise; } +interface Htmx { + ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise; + process(element: Element): void; + trigger(element: Element | string, eventName: string, detail?: Record): void; +} + interface Window { - htmx: { - ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise; - process(element: Element): void; - }; + htmx: Htmx; } diff --git a/web/src/stats.ts b/web/src/stats.ts index a94b4f0a..c26de780 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -151,11 +151,6 @@ function text(id: string, value: string): void { if (el) el.textContent = value; } -function getActiveRange(): string { - const btn = document.querySelector(".stats-range-btn.btn-active") as HTMLElement | null; - return btn?.dataset.range || "7"; -} - function markRangeButtons(active: string): void { document.querySelectorAll(".stats-range-btn").forEach((btn) => { const isActive = btn.dataset.range === active; @@ -359,7 +354,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { tooltip: { callbacks: { label: (ctx) => { - const v = typeof ctx.parsed === "number" ? ctx.parsed : ctx.parsed.y; + // Scalar for bar charts, {x, y} points for line charts. + const parsed: number | { y: number } = ctx.parsed; + const v = typeof parsed === "number" ? parsed : parsed.y; return `${Math.round(v * 10) / 10} ${unit}`; }, }, diff --git a/web/src/toast.ts b/web/src/toast.ts index 37f85526..9ce3d6d4 100644 --- a/web/src/toast.ts +++ b/web/src/toast.ts @@ -1,7 +1,7 @@ // Toast notification system for backend errors // Displays toast notifications at the top of the page -type ToastType = "error" | "success" | "info"; +type ToastType = "error" | "success" | "info" | "warning"; const TOAST_DEFAULT_DURATION = 5000; @@ -39,6 +39,12 @@ const getToastConfig = (type: ToastType) => { bgClass: "bg-blue-500/90", icon: "ℹ️", }, + warning: { + // bg-yellow-500 (no opacity modifier): it must already appear in the + // templates for the Tailwind build to emit the class. + bgClass: "bg-yellow-500", + icon: "⚠️", + }, }; return configs[type]; }; @@ -109,7 +115,7 @@ const parseXHRError = (xhr: XMLHttpRequest): string => { try { const response = JSON.parse(xhr.responseText); errorMessage = response.error || response.message || errorMessage; - } catch (e) { + } catch { errorMessage = xhr.responseText || errorMessage; } return errorMessage; @@ -154,7 +160,7 @@ const setupHTMXListeners = (): void => { customEvent.detail.target.innerHTML = ""; } } - } catch (e) { + } catch { // Not valid JSON — ignore } }