56 KiB
TypeScript Conversion Plan for Bookhoard
Executive Summary
Convert ~2,800 lines of inline JavaScript across 14 template files into organized TypeScript modules while preserving the existing Hybrid SSR architecture. The web UI will remain a client of the JSON API, alongside Kobo, KOReader, and future plugins.
Core Strategy: Server-Side Rendering (SSR) for initial page loads + TypeScript for CRUD operations via existing JSON API.
Current Architecture Analysis
Already Converted to TypeScript ✅
| Source | Output | Lines | Purpose |
|---|---|---|---|
web/src/toast.ts |
toast.js |
226 | Toast notifications, HTMX/fetch interceptors |
web/src/theme.ts |
theme.js |
130 | Theme management, smooth scrolling |
web/src/header.ts |
header.js |
114 | Header dropdowns, theme/user menus |
web/src/device-management.ts |
device-management.js |
102 | Device token copy/regeneration |
Remaining Standalone JavaScript ❌
| File | Lines | Purpose |
|---|---|---|
web/static/search.js |
275 | Header search with keyboard navigation, debouncing |
Inline JavaScript in Templates (~2,800 lines)
| Template | Script Lines | Primary Functions |
|---|---|---|
dashboard.templ |
~456 | Statistics, recent activity |
docs.templ |
~392 | Documentation search, sidebar toggle |
devices.templ |
~386 | Token regeneration, sync URL display |
collections.templ |
~362 | Bulk operations, filtering |
unlinked_books.templ |
~312 | Book matching, linking, bulk operations |
collection_rules.templ |
~239 | Rule CRUD operations, testing |
admin_library.templ |
~229 | Admin library scan |
bookshelf.templ |
~200 | Book viewing, pagination |
api_explorer.templ |
~103 | API testing, cURL generation |
admin.templ |
~29 | Admin dashboard actions |
login.templ |
~29 | Login theme selection |
index.templ |
~24 | Landing page theme preview |
admin_profile.templ |
~11 | Admin profile management |
progress.templ |
~6 | Reading progress |
conflicts.templ |
0 | No inline JS - uses onclick handlers only |
queue.templ |
0 | No inline JS - uses onclick handlers only |
analytics.templ |
0 | No inline JS - uses onclick handlers only |
Total: ~2,800 lines of inline JavaScript to convert
Note: conflicts.templ, queue.templ, and analytics.templ have NO <script> blocks. They use simple onclick="window.functionName()" handlers that call functions defined elsewhere. These templates only need TypeScript modules for their handler functions, no template cleanup required.
Architecture: Hybrid SSR + TypeScript CRUD
Current Pattern (Already Working)
┌─────────────────────────────────────────────────────────────┐
│ User visits /collections │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 1. Go handler: GET /collections │
│ 2. Fetches data from CollectionService │
│ 3. Renders templates.Collection(user, collections) │
│ 4. Returns complete HTML page with data │
└─────────────────────────────────────────────────────────────┘
↓
[Page displays instantly with data]
↓
┌─────────────────────────────────────────────────────────────┐
│ 5. User clicks "Add Rule" button │
│ 6. TypeScript: fetch POST /api/collections/{id}/rules │
│ 7. Server returns JSON │
│ 8. TypeScript updates DOM │
└─────────────────────────────────────────────────────────────┘
What This Preserves
✅ Fast initial page loads - SSR with data
✅ Progressive enhancement - Works without JavaScript
✅ Single API - All clients use /api/* endpoints
✅ Service layer as source of truth - All handlers use same services
✅ No handler duplication - No new HTML endpoints needed
✅ Plugin ecosystem ready - Plugins use JSON API
Proposed TypeScript Structure
Continuing with existing web/src/ pattern:
web/src/
├── types/
│ └── api.d.ts # NEW: Centralized API type definitions (.d.ts = no JS output)
├── toast.ts # ✅ Already converted
├── theme.ts # ✅ Already converted
├── header.ts # ✅ Already converted
├── device-management.ts # ✅ Already converted
├── search.ts # NEW: Convert from search.js
├── storage.ts # NEW: localStorage wrapper with types
├── dom.ts # NEW: DOM utilities (escapeHtml, querySelector)
├── api.ts # NEW: API client functions with auth
├── events.ts # NEW: Event delegation helpers (likely needed)
├── collections.ts # NEW: Convert from inline JS (rules, bulk)
├── linking.ts # NEW: Convert from inline JS (matcher, manual/bulk link)
├── bookshelf.ts # NEW: Convert from inline JS (display, pagination)
├── api-explorer.ts # NEW: Convert from inline JS (request, response, curl)
├── admin.ts # NEW: Convert from inline JS (scan, stats)
├── analytics.ts # NEW: Analytics data loading, Chart.js integration
├── queue.ts # NEW: Queue management, filtering
├── conflicts.ts # NEW: Conflict resolution, bulk operations
└── docs.ts # NEW: Documentation search, sidebar toggle
All TypeScript files compile to flat web/static/ directory:
- Source:
web/src/*.ts→ Output:web/static/*.js - No subdirectories in output (TypeScript
module: "none"= browser globals) - All functions exported to
windowobject for HTML attribute access
Type Sharing Strategy
Overview: Manual Type Definitions with Compile-Time Checking
No code generation. We manually maintain TypeScript types that match Go handler JSON responses.
Why manual instead of code generation?
- Go's
pgtype.Text,pgtype.Timestamp,pgtype.Numericdon't map cleanly to TypeScript generators - We only care about the JSON contract, not Go storage types
- API contracts change infrequently in stable applications
- Manual verification via Bruno tests is sufficient
- No additional tooling or build complexity
How it works:
- All API types defined in
web/src/types/api.d.ts(.d.ts files don't generate JS output) - Import types using
import type { ... }for compile-time checking - TypeScript erases type imports (no runtime dependencies)
- When adding new API fields, update the type definition
- Reference Go handler files in comments for cross-checking
- Bruno tests catch contract mismatches
Template-Handler Type Sharing:
- Go templates import handlers directly:
import "bookhoard/internal/handlers" - Templates use handler types for SSR data:
handlers.CollectionData,handlers.BookInfo - TypeScript interfaces must match the SAME JSON responses that templates receive
- Example: If template uses
handlers.BookInfo, TypeScript uses matchingBookInfointerface - This ensures SSR and TypeScript code work with identical data structures
Principle: Define TypeScript Interfaces That Match Go Handler JSON Responses
❌ DON'T DO THIS:
// Using PascalCase when API returns snake_case
interface MediaItem {
ID: string; // Wrong: JSON has "id"
Title: string; // Wrong: JSON has "title"
LibraryID: string; // Wrong: JSON has "library_id"
}
✅ DO THIS:
// Define TypeScript interfaces that MATCH Go JSON tag names exactly
// These types are returned by /api/* endpoints as JSON
// Matches database.SearchMediaItemsRow JSON response from /api/media-items/search
// Source: internal/database/queries.sql.go (SearchMediaItemsRow struct)
// Note: internal/handlers/search.go has an unused MediaItemSummary struct - ignore it
interface MediaItemSummary {
id: string;
title: string;
author?: string; // Single author string, not array
library_id: string;
library_type_name: 'ebooks' | 'comics' | 'manga';
library_name: string;
cover_image_path?: string;
}
// Matches handlers.CollectionData JSON response
interface CollectionData {
id: string;
name: string;
description: string;
color: string;
icon: string;
}
Why:
- Go structs use PascalCase for field names but JSON tags use snake_case
- API responses are JSON with snake_case field names
- TypeScript interfaces must match the JSON output, not Go struct names
- Easy to verify: check the
json:"..."tag in Go handler code - Example:
type BookInfo struct { MediaItemID string \json:"media_item_id"` }→ TypeScript usesmedia_item_id`
Important: Some handlers return database rows directly (e.g., SearchMediaItemsRow), not handler-defined structs. Always check what the endpoint actually returns, not just what handler structs are defined.
Example: Type Definition File
// web/src/types/api.d.ts
// ============================================
// API Type Definitions
// ============================================
// These types match the JSON responses from /api/* endpoints.
// Source of truth: Check what the endpoint ACTUALLY returns:
// 1. Database layer: internal/database/queries.sql.go (SearchMediaItemsRow, etc.)
// 2. Handler structs: internal/handlers/*.go (check json:"..." tags)
// 3. Test by calling endpoint and inspecting JSON response
//
// When API contracts change:
// 1. Find the endpoint function in internal/handlers/*.go
// 2. Check what it returns (database row or struct)
// 3. Check the JSON tags: `json:"field_name"`
// 4. Map pgtype fields to TypeScript types:
// - pgtype.Text → string | undefined
// - pgtype.UUID → string
// - pgtype.Timestamp → string (ISO datetime)
// - pgtype.Numeric → number or string (for precision)
// 5. Update the interface below with snake_case field names
// 6. Run Bruno tests to verify
// ============================================
// Matches database.SearchMediaItemsRow from /api/media-items/search
// Source: internal/database/queries.sql.go:SearchMediaItemsRow
// Endpoint: internal/handlers/media.go:SearchMediaItems()
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
// Used in: search.ts
export interface MediaItemSummary {
id: string;
title: string;
author?: string; // Single author string from database
library_id: string;
library_type_name: 'ebooks' | 'comics' | 'manga';
library_name: string;
cover_image_path?: string;
}
// Matches handlers.CollectionData JSON response
// Used in: collections.ts
export interface CollectionData {
id: string;
name: string;
description: string;
color: string;
icon: string;
created_at: string;
}
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
// JSON tags: media_item_id, title, author, cover_image_path
// Used in: collections.templ (server-rendered), collections.ts
export interface BookInfo {
media_item_id: string;
title: string;
author: string;
cover_image_path: string;
}
// Matches handlers.UnlinkedBookData JSON response
// Used in: unlinked_books.ts, unlinked_books.templ
export interface UnlinkedBookData {
progress_id: string;
device_id: string;
device_name: string;
device_type: 'koreader' | 'kobo' | 'web';
title_from_device: string;
file_path: string;
sha256: string;
last_sync_time: string;
confidence_score: number;
potential_matches: PotentialMatchData[];
}
export interface PotentialMatchData {
media_item_id: string;
title: string;
author: string;
confidence: number;
cover_image_path?: string;
}
// Matches collection rule objects
// Used in: collection_rules.ts
export interface CollectionRule {
id: string;
field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags';
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than';
value: string;
enabled: boolean;
priority: number;
}
// Matches API test rule responses
// Used in: collection_rules.ts (test results)
export interface TestRuleMatch {
title: string;
author: string;
cover_image_path?: string;
}
// Matches handlers.SearchResponse (internal/handlers/search.go)
export interface SearchResponse {
results: SearchBookResponse[];
total: number;
}
export interface SearchBookResponse {
id: string;
title: string;
authors: SearchAuthor[];
}
export interface SearchAuthor {
first_name: string;
last_name: string;
}
// Matches AuthResponse (internal/handlers/auth.go:59-65)
export interface AuthResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
user: UserProfile;
}
export interface UserProfile {
id: string;
email: string;
username: string;
first_name?: string;
last_name?: string;
role: string;
theme?: string;
}
// Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35)
// Used in: analytics.ts
export interface ReadingStatsResponse {
total_books_read: number;
total_pages_read: number;
total_reading_time_minutes: number;
average_session_time_minutes: number;
longest_session_minutes: number;
most_active_day_of_week: string;
completion_rate: number;
daily_reading_minutes: DailyReading[];
}
export interface DailyReading {
date: string;
minutes: number;
pages: number;
}
// Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45)
// Note: Response is wrapped: { devices: DeviceUsage[] }
// Used in: analytics.ts
export interface DeviceUsageResponse {
devices: DeviceUsage[];
}
export interface DeviceUsage {
device_id: string;
device_name: string;
device_type: string;
sync_count: number;
last_sync: string;
total_time_seconds: number;
total_time_minutes: number;
}
// Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59)
// Note: Response is wrapped: { books: PopularBook[] }
// Used in: analytics.ts
export interface PopularBooksResponse {
books: PopularBook[];
}
export interface PopularBook {
media_item_id: string;
title: string;
author: string;
read_count: number;
avg_completion: number;
last_read: string;
}
// Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51)
// Used in: queue.ts
export interface QueueItemResponse {
id: string;
device_id: string;
device_name: string;
device_type: string;
media_item_id?: string;
media_title?: string;
user_email: string;
sync_type: string;
priority: number;
attempts: number;
max_attempts: number;
status: string;
error_message?: string;
created_at: string;
processed_at?: string;
}
// Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33)
// Used in: queue.ts
export interface QueueStatsResponse {
pending_count: number;
processing_count: number;
failed_count: number;
completed_count: number;
total_count: number;
}
// Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53)
// Used in: conflicts.ts
export interface ConflictDetailResponse {
id: string;
media_item_id: string;
media_item_title: string;
conflict_type: string;
conflict_data: Record<string, ConflictSourceData>;
resolution_status: string;
resolution_data?: Record<string, unknown>;
resolved_by?: string;
resolved_at?: string;
created_at: string;
}
// Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40)
export interface ConflictSourceData {
source: string;
timestamp: string;
data: Record<string, unknown>;
}
// Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59)
// Used in: conflicts.ts
export interface ConflictListResponse {
conflicts: ConflictDetailResponse[];
total: number;
unresolved: number;
}
// Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65)
// Used in: conflicts.ts
export interface ConflictResolveResponse {
conflict_resolved: boolean;
applied_to: Record<string, boolean>;
devices_synced: string[];
}
// Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429)
// Used in: conflicts.ts
export interface BulkResolveResponse {
results: ConflictResult[];
total: number;
success: number;
failed: number;
}
// Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436)
export interface ConflictResult {
conflict_id: string;
status: string;
error?: string;
winner?: string;
}
Importing Types (No ES Modules Required)
With module: "none" in tsconfig.json, use import type for compile-time checking:
// web/src/collections.ts
import type { CollectionData, MediaItemSummary } from './types/api';
function renderCollection(collection: CollectionData): HTMLElement {
const div = document.createElement('div');
div.textContent = collection.name;
return div;
}
// Export to window for HTML access
(window as any).renderCollection = renderCollection;
What happens at compile time:
- TypeScript verifies types match the interfaces
- The
import typestatement is erased (no runtime import) - Output JavaScript has no
require()orimportstatements web/src/types/api.d.tsproduces no.jsfile (.d.ts = type-only)
Output (web/static/collections.js):
function renderCollection(collection) {
var div = document.createElement('div');
div.textContent = collection.name;
return div;
}
window.renderCollection = renderCollection;
Conversion Phases
Phase 1: Complete Standalone File Conversion
Priority: High | Effort: 1 day | Dependencies: None
Tasks:
- Convert
web/static/search.js→web/src/search.ts - Add proper types for search results (use
MediaItemSummarywith snake_case fields) - Extract keyboard navigation logic into pure functions
- Update templates to use new TypeScript module
Important: Use snake_case field names matching JSON responses:
item.id(notitem.ID)item.title(notitem.Title)item.author(single string, notitem.Authorsarray)item.library_id(notitem.LibraryID)item.library_type_name(notitem.LibraryTypeName)
Note: Ignore the MediaItemSummary struct in internal/handlers/search.go (it's unused and incorrect). The actual API returns SearchMediaItemsRow from the database layer, which has the correct fields.
Build Verification:
- Run
npm run build:tsto verify compilation - Confirm
web/static/search.jsis generated - Test search functionality in browser
- Verify keyboard navigation (up/down arrows, Enter, Escape)
Deliverable: All standalone JavaScript converted to TypeScript
Files:
- Create:
web/src/search.ts - Delete:
web/static/search.js - Update: Templates referencing search functions (header.templ)
Phase 2: Core Utilities (Shared Infrastructure)
Priority: High | Effort: 1-2 days | Dependencies: None
Tasks:
-
Create
web/src/types/api.d.ts- Centralized type definitions for all API responses
- Critical: Check what endpoints ACTUALLY return (database rows or structs)
- Reference source files in comments:
internal/database/queries.sql.gofor database rowsinternal/handlers/*.gofor handler-defined structs
- Critical: Use snake_case field names matching Go JSON tags
- Example:
json:"media_item_id"→media_item_id: string - Use
.d.tsextension (no JS output) - Warning: Some handler structs are unused/incorrect (e.g., search.go's MediaItemSummary)
- Always verify by checking the endpoint's actual return statement
-
Create
web/src/storage.ts- localStorage wrapper with type safety
- Token management helpers
- Theme persistence
-
Create
web/src/dom.ts- escapeHtml utility
- querySelector wrappers with null checks
- Element creation helpers
-
Create
web/src/api.ts- API client functions (procedural, no classes)
- Automatic auth header injection
- Error handling integration with toast system
-
Create
web/src/events.ts(likely needed for dynamic content)- Event delegation helpers
- Data attribute selectors
- Reusable event handler patterns
Build Verification:
- Run
npm run build:tsto verify all modules compile - Confirm
.jsfiles generated inweb/static/ - Verify
types/api.d.tsproduces no.jsfile - Test utility functions in browser console
Deliverable: Reusable utilities for all feature modules
Files:
- Create:
web/src/storage.ts - Create:
web/src/dom.ts - Create:
web/src/api.ts - Optionally:
web/src/events.ts(if event delegation is needed)
Phase 3: Low Complexity Features (Search, Header, Admin)
Priority: Medium | Effort: 3-4 days | Dependencies: Phase 2
Tasks:
3.1 Search Module (from Phase 1)
- Refine search logic in
web/src/search.ts(created in Phase 1) - Verify keyboard navigation works correctly
- Add proper types from
types/api.d.ts - Test debounced search functionality
3.2 Header Dropdowns (Review Only)
- Review
web/src/header.ts(already converted to TypeScript) - Ensure patterns are consistent and can serve as reference
- Verify event delegation follows best practices
- Confirm theme switching logic is correct
3.3 Admin Actions
- Quick scan trigger
- System stats display
- Admin profile updates
3.4 Analytics Module
- Create
web/src/analytics.ts loadAnalytics()function - fetches stats from/api/analytics/stats,/api/analytics/devices,/api/analytics/popular- Chart.js initialization:
- Daily reading minutes chart
- Device usage chart
- Popular books display
- Date range filtering
- Note: This is pure client-side data loading (no SSR data passed to template)
- Uses Chart.js CDN (already included in template)
Build Verification:
- Run
npm run build:tsto verify compilation - Confirm
web/static/admin.jsandweb/static/analytics.jsare generated - Test admin and analytics functionality in browser
- Verify analytics page loads data and displays charts
- Test date range filtering
Deliverable: Search, header, admin, and analytics features in TypeScript
Files:
- Refine:
web/src/search.ts(created in Phase 1, add types) - Review:
web/src/header.ts(already converted, ensure patterns consistent) - Create:
web/src/admin.ts(scan, stats, profile management) - Create:
web/src/analytics.ts(data loading, Chart.js integration)
Phase 4: Medium Complexity Features (Collections, Bookshelf, Devices, Queue)
Priority: Medium | Effort: 5-6 days | Dependencies: Phase 2, 3
Tasks:
4.1 Collection Rules
- Convert rule CRUD from
collection_rules.templ - Use
/api/collections/{id}/rulesendpoints - Rule testing functionality
- Bulk rule operations
4.2 Bookshelf Display
- Convert from
bookshelf.templ - Pagination logic
- Library selection state
- Book viewing interactions
4.3 Device Management
- Convert from
devices.templ - Token regeneration
- Sync URL display
- Device registration
- Note:
web/src/device-management.tsalready exists for some functionality - Integrate with existing device-management module as needed
4.4 Queue Management
- Create
web/src/queue.ts processPendingItems()- POST to/api/queue/processclearFailedItems()- DELETE to/api/queue/failedclearAllItems()- DELETE to/api/queue/allfilterQueue()- client-side filtering by status, type, devicerefreshQueue()- reload queue data from server- Modal for queue item details
- Note: Template uses SSR for initial queue data (receives
QueueItemResponse,QueueStatsResponse) - CRUD operations happen via TypeScript
Build Verification:
- Run
npm run build:tsto verify compilation - Confirm
.jsfiles generated for collections, bookshelf, devices, and queue - Test all functionality in browser
Deliverable: Collections, bookshelf, devices, and queue features in TypeScript
Files:
- Create:
web/src/collections.ts(rules CRUD, bulk operations, rule testing) - Create:
web/src/bookshelf.ts(display, pagination, library selection) - Note: Device management functionality already exists in
web/src/device-management.ts; integrate with devices.templ conversion as needed
Phase 5: High Complexity Features (Linking, Bulk Operations, Conflicts)
Priority: Low | Effort: 7-8 days | Dependencies: Phase 4
Tasks:
5.1 Book Linking
- Convert matching logic from
unlinked_books.templ - Search and match functionality
- Manual link modal
- Bulk auto-link
- Bulk get suggestions
5.2 API Explorer
- Convert from
api_explorer.templ - Request/response display
- cURL generation
- History tracking
5.3 Conflict Resolution
- Create
web/src/conflicts.ts filterConflicts()- client-side filtering by statusdismissAllResolved()- POST to/api/conflicts/dismiss-resolvedbulkResolve()- POST to/api/conflicts/bulk-resolvewith strategy (most_recent, highest_progress)bulkDismiss()- POST to/api/conflicts/bulk-dismisshandleResolveConflict()- POST to/api/conflicts/{id}/resolve- Select winner device (koreader, kobo, web, manual)
- Manual override inputs (percentage, page, epubcfi, chapter)
- Resolution reason
showConflictModal(),hideConflictModal()- modal managementupdateBulkActions()- update UI based on selected conflictstoggleAllConflicts()- select/deselect all conflicts- Note: Template uses SSR for initial conflicts list (receives
ConflictDetailResponse) - All resolution operations happen via TypeScript
Build Verification:
- Run
npm run build:tsto verify compilation - Confirm
.jsfiles generated for linking, api-explorer, and conflicts - Test all functionality in browser
- Verify conflict resolution (individual and bulk)
- Test modal interactions
Deliverable: Book linking, API explorer, and conflict resolution in TypeScript
Files:
- Create:
web/src/linking.ts(matching, manual/bulk link, suggestions) - Create:
web/src/api-explorer.ts(request, response, cURL generation, history) - Create:
web/src/conflicts.ts(conflict resolution, bulk operations, modals)
Phase 6: Template Integration & Cleanup
Priority: High | Effort: 3-4 days | Dependencies: All phases
Tasks:
6.1 Update Templates
- Replace
<script>blocks with<script src="/static/[feature].js"> - Remove inline logic (functions, fetch calls, state management)
- Keep simple
onclick="window.functionName()"calls where appropriate - Use
data-actionattributes only for dynamic content or when event delegation simplifies code
6.1.1 Login Template Cleanup (Explicit Task)
- File:
templates/login.templ - Remove: Lines 62-88 (duplicate
applyTheme,loadTheme,changeThemefunctions) - Add:
<script src="/static/theme.js"></script>in<head> - Why: Theme logic already exists in
web/src/theme.ts, remove duplication - Verify: Theme dropdown still works after cleanup
6.2 Verify Progressive Enhancement
- Test all pages with JavaScript disabled
- Ensure forms submit with full page reload (see Progressive Enhancement Pattern below)
- Verify critical paths work without JS
6.3 Build Configuration
- Update
tsconfig.jsonwith new file structure (if needed) - Verify all
.tsfiles compile to.jsinweb/static/ - Add source maps for debugging (optional)
6.4 Testing
- Verify all features work with TypeScript
- Check keyboard navigation
- Test form submissions
- Validate error handling
6.5 Documentation Search Integration
- Create
web/src/docs.ts toggleSidebar()- sidebar visibility toggle for mobile- Documentation search functionality:
- Initialize lunr.js search index
- Search input debouncing
- Display search results
- Navigate to search results
- Remove inline script from
docs.templ - Note: Template uses external libraries (lunr.js, lunr-flex.js) for search
- Keep it simple - no complex search UI, just basic functionality
Build Verification:
- Run
npm run build:tsto verify entire project compiles - Confirm all
.jsfiles generated inweb/static/ - No TypeScript compilation errors
- Full manual testing of all features
- Test documentation search functionality
- Verify sidebar toggle works on mobile
Deliverable: Clean templates, all inline JavaScript replaced with TypeScript modules, documentation search functional
Code Style Guidelines
1. Procedural/Imperative with Functional Techniques
✅ GOOD:
// Pure function, no classes
function createRuleItem(rule: Rule): HTMLElement {
const div = document.createElement('div');
div.className = 'rule-item';
div.innerHTML = renderRuleHTML(rule);
return div;
}
// Event delegation (one listener)
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const deleteBtn = target.closest('[data-action="delete-rule"]');
if (deleteBtn) {
const ruleId = deleteBtn.dataset.ruleId;
deleteRule(ruleId);
}
});
❌ BAD:
// Class-based, OOP
class RuleManager {
private rules: Rule[] = [];
constructor() { ... }
addRule(rule: Rule) { ... }
}
2. Type Definitions
Define TypeScript Interfaces Matching Go Handler JSON Tags:
// Matches internal/handlers/book_matching.go
// Check JSON tags: `json:"progress_id"`, `json:"device_id"`, etc.
// Reference: Find struct definition in Go handler file
interface UnlinkedBookData {
progress_id: string;
device_id: string;
device_name: string;
device_type: 'koreader' | 'kobo' | 'web';
title_from_device: string;
file_path: string;
sha256: string;
last_sync_time: string;
confidence_score: number;
potential_matches: PotentialMatchData[];
}
interface PotentialMatchData {
media_item_id: string;
title: string;
author: string;
confidence: number;
cover_image_path?: string;
}
Important: Database Rows vs Handler Structs
Some endpoints return database rows directly (e.g., SearchMediaItemsRow), not handler-defined structs. Always check the endpoint's return statement:
// internal/handlers/media.go:SearchMediaItems()
return c.JSON(http.StatusOK, partialResults) // Returns []SearchMediaItemsRow from database
When in doubt, grep the endpoint function and check what it actually returns.
Only Create Frontend-Specific Types When Necessary:
// OK: Frontend-specific state
type SearchState = {
query: string;
results: MediaItem[];
selectedIndex: number;
};
// OK: UI-specific config
type ToastType = 'error' | 'success' | 'info';
3. Error Handling
Integrate with Toast System:
async function deleteRule(ruleId: string): Promise<void> {
try {
const response = await (window as any).api.delete(`/collections/rules/${ruleId}`);
if (!response.ok) {
const error = await response.json();
(window as any).showToast.error(error.message || 'Failed to delete rule');
return;
}
(window as any).showToast.success('Rule deleted');
removeRuleFromDOM(ruleId);
} catch (error) {
(window as any).showToast.error('Network error: Unable to connect to server');
console.error('Delete rule error:', error);
}
}
4. API Client Pattern
Procedural API Client (No Classes):
// web/src/api.ts
function getAuthHeader(): string {
const token = localStorage.getItem('token');
return token ? `Bearer ${token}` : '';
}
async function apiGet(url: string): Promise<Response> {
return fetch(`/api${url}`, {
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
}
});
}
async function apiPost(url: string, data: unknown): Promise<Response> {
return fetch(`/api${url}`, {
method: 'POST',
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
}
async function apiDelete(url: string): Promise<Response> {
return fetch(`/api${url}`, {
method: 'DELETE',
headers: {
'Authorization': getAuthHeader()
}
});
}
async function apiPut(url: string, data: unknown): Promise<Response> {
return fetch(`/api${url}`, {
method: 'PUT',
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
}
// Export to window for use in other modules
(window as any).api = {
get: apiGet,
post: apiPost,
delete: apiDelete,
put: apiPut
};
5. Event Handler Pattern (Pragmatic Mix)
Use onclick for Simple Static Content:
<!-- ✅ GOOD: Simple onclick for server-rendered content -->
<button onclick="window.deleteRule('{rule.id}')">Delete</button>
When to use:
- Static server-rendered HTML
- Simple function calls to well-named functions
- When you want explicit, readable HTML
Use data-action + Event Delegation for Dynamic Content:
<!-- ✅ GOOD: Event delegation for dynamically added rows -->
<button data-action="delete-rule" data-rule-id="{rule.id}">Delete</button>
// Event delegation (one listener handles all dynamic items)
document.addEventListener('click', async (e) => {
const target = e.target as HTMLElement;
const deleteBtn = target.closest('[data-action="delete-rule"]');
if (deleteBtn) {
const ruleId = deleteBtn.dataset.ruleId;
if (ruleId && confirm('Are you sure you want to delete this rule?')) {
await deleteRule(ruleId);
}
}
});
When to use:
- Dynamic content (rows added after page load)
- Lists where every item needs the same handler
- When event delegation genuinely simplifies the code
Use Form Interception for Progressive Enhancement:
<!-- ✅ GOOD: Form works without JavaScript, enhanced with JS -->
<form action="/collections/{id}/rules" method="POST">
<input type="text" name="value" required>
<button type="submit">Add Rule</button>
</form>
When to use:
- Forms that must work without JavaScript
- Progressive enhancement is required
- Critical user flows
❌ DON'T:
- Mandate data-action everywhere (over-engineering)
- Remove all onclick handlers (unnecessary refactoring)
✅ DO:
- Remove inline
<script>blocks with complex logic - Extract to TypeScript modules attached to window
- Match existing patterns from toast.ts, theme.ts, header.ts
- Use the pattern that makes sense for each specific case
6. Progressive Enhancement
Progressive Enhancement Pattern:
All critical user flows must work without JavaScript. The application uses HTMX for most forms, which provides progressive enhancement automatically.
⚠️ Current HTMX Forms Limitation (VERIFIED):
The existing HTMX forms do NOT have action attributes, meaning they will NOT work without JavaScript. This is a pre-existing condition, not something this conversion plan introduces:
<!-- Current forms LACK action attributes -->
<form hx-post="/api/auth/login" hx-target="#result"> <!-- NO action/method -->
Options (outside scope of this plan):
- Add
actionattributes to HTMX forms for true progressive enhancement - Accept that login/register flows require JavaScript (current state)
- Create separate server-rendered login fallback pages
This conversion plan maintains the current behavior - it does not fix or worsen progressive enhancement. The goal is to convert inline JavaScript to TypeScript without breaking existing functionality.
HTMX Forms (Current State):
<!-- login.templ - Uses HTMX, but no action fallback -->
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML">
<input type="text" name="login" required>
<input type="password" name="password" required>
<button type="submit">Sign In</button>
</form>
<div id="result"></div>
How Current HTMX Forms Work:
- With JavaScript: HTMX intercepts submit, posts to API, updates DOM without reload
- Without JavaScript: Form does NOT submit (no action attribute)
- This is pre-existing behavior - not introduced by this conversion
When to Add TypeScript Form Interception: Use TypeScript interception only when you need custom behavior beyond HTMX:
// Example: Custom form handling with redirect
// web/src/auth.ts
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form[action="/api/auth/login"]') as HTMLFormElement;
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const response = await fetch('/api/auth/login', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('token', data.access_token);
window.location.href = '/dashboard';
} else {
(window as any).showToast.error('Login failed');
}
});
});
Guidelines:
- Prefer HTMX for forms (already progressive enhanced)
- Add TypeScript only when you need custom logic
- All forms must have
actionandmethodattributes - Test with JavaScript disabled before completing conversion
- HTMX: works without JS, enhanced with JS
- Custom TypeScript: must handle both cases
Build & Deployment
TypeScript Configuration
Current tsconfig.json (already configured correctly):
{
"compilerOptions": {
"target": "ES2020",
"module": "none",
"lib": ["ES2020", "DOM"],
"outDir": "./web/static",
"rootDir": "./web/src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"web/src/**/*.ts"
],
"exclude": [
"node_modules"
]
}
Key points:
module: "none"= Browser globals (no ES modules)rootDir: "./web/src"= All TypeScript in src/outDir: "./web/static"= Flat output structure- No bundler needed - TypeScript compiles directly to browser-compatible JavaScript
Build Scripts
Current package.json (no changes needed):
{
"scripts": {
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
"build:ts": "tsc",
"build:ts:watch": "tsc --watch"
}
}
Note: No additional build scripts needed. Docker handles production builds, and tsc compilation is environment-agnostic. Run npm run build:ts:watch for development.
Output Structure
Compiled JavaScript (flat structure in web/static/):
web/static/
├── toast.js # ✅ Already exists
├── theme.js # ✅ Already exists
├── header.js # ✅ Already exists
├── device-management.js # ✅ Already exists
├── search.js # NEW (from search.js)
├── storage.js # NEW
├── dom.js # NEW
├── api.js # NEW
├── collections.js # NEW
├── linking.js # NEW
├── bookshelf.js # NEW
├── api-explorer.js # NEW
├── admin.js # NEW
├── analytics.js # NEW
├── queue.js # NEW
├── conflicts.js # NEW
└── docs.js # NEW
No subdirectories - TypeScript with module: "none" compiles to flat output matching source structure.
Testing Strategy
1. Unit Testing (Optional)
Test pure functions that don't depend on DOM:
// Test utilities
describe('escapeHtml', () => {
it('should escape HTML entities', () => {
expect(escapeHtml('<script>')).to.equal('<script>');
});
});
// Test formatters
describe('formatConfidence', () => {
it('should format confidence as percentage', () => {
expect(formatConfidence(0.95)).to.equal('95%');
});
});
2. Integration Testing
Test API interactions:
describe('deleteRule', () => {
it('should call DELETE /api/collections/rules/{id}', async () => {
const fetchSpy = sinon.stub(global, 'fetch');
await deleteRule('rule-123');
expect(fetchSpy.calledWith('/api/collections/rules/rule-123', {
method: 'DELETE'
})).to.be.true;
});
});
3. Manual Testing Checklist
- All CRUD operations work (create, read, update, delete)
- Toast notifications display correctly
- Error handling works (network errors, 500 errors, 404 errors)
- Keyboard navigation works (search, modals)
- Forms submit correctly
- Bulk operations work
- Progressive enhancement (test with JS disabled)
- All pages load with SSR data
- No console errors
4. Browser Testing
Test in:
- Chrome/Edge (Chromium)
- Firefox
- Safari (if available)
- Mobile browsers (responsive testing)
Progressive Enhancement Verification
Test Without JavaScript
Disable JavaScript in browser:
- Open DevTools
- Settings → Disable JavaScript
- Reload page
- Verify critical functionality works
What Should Work:
- ✅ Page loads with SSR data
- ✅ Navigation links work (full page reload)
- ✅ Forms submit (full page reload, not AJAX)
- ✅ All data visible on initial load
What Won't Work (Expected):
- ❌ Dynamic updates without page reload
- ❌ Toast notifications
- ❌ Keyboard navigation
- ❌ Modal dialogs
- ❌ Auto-complete search
Mitigation:
- Ensure forms have proper
actionandmethodattributes - Provide submit buttons for all operations
- Include helpful error messages in HTML responses
Risk Mitigation
Potential Issues
1. Breaking Changes During Conversion
- Risk: Converting inline JS breaks functionality
- Mitigation: Convert incrementally, test each feature
- Rollback: Keep inline JS until TypeScript is verified
2. Type Mismatches with Go Handlers
- Risk: TypeScript types don't match JSON responses
- Mitigation: Use Go handler types as source of truth
- Validation: Test API responses match TypeScript types
3. Progressive Enhancement Regression
- Risk: Pages require JavaScript to function
- Mitigation: Test with JS disabled before/after
- Validation: Forms must have action/method attributes
4. Build Complexity
- Risk: TypeScript compilation becomes bottleneck
- Mitigation: Use existing
tscsetup, add watch mode - Validation: Build time under 5 seconds
5. Bundle Size
- Risk: Too much JavaScript loaded
- Mitigation: Split by feature, load per-page
- Validation: Monitor bundle sizes, code splitting
Rollback Strategy
If conversion fails:
- Git revert TypeScript files
- Inline JavaScript still works (wasn't deleted yet)
- Fix issues and retry
- Delete inline JS only after TypeScript verified
Per-feature rollback:
# Before converting feature
git branch backup-before-collections
# If collections conversion breaks
git checkout backup-before-collections -- templates/collections.templ
# Fix TypeScript and try again
Estimated Timeline
| Phase | Duration | Dependencies | Deliverables |
|---|---|---|---|
| Phase 1: Standalone Conversion | 1 day | None | Search converted to TS |
| Phase 2: Core Utilities | 1-2 days | None | Storage, DOM, API, events modules |
| Phase 3: Low Complexity | 2-3 days | Phase 2 | Search, header, admin, analytics in TS |
| Phase 4: Medium Complexity | 3-4 days | Phase 2, 3 | Collections, bookshelf, devices, queue in TS |
| Phase 5: High Complexity | 4-5 days | Phase 4 | Linking, API explorer, conflicts in TS |
| Phase 6: Integration & Cleanup | 2-3 days | All phases | Clean templates, docs search, verified functionality |
| Total | 13-18 days | Full TypeScript conversion |
Note: Timeline reduced from initial estimate based on actual line count verification (~2,800 lines vs original ~6,300 estimate). Three templates (conflicts, queue, analytics) have NO inline script blocks and only need TypeScript modules for their onclick handlers.
Success Criteria
- All inline JavaScript logic removed from templates (functions, fetch calls, state management)
- All features work with TypeScript
- TypeScript compilation passes with strict mode
- No regression in existing progressive enhancement behavior (HTMX forms unchanged)
- No type
any(except for legacy API responses) - Event handlers use pragmatic mix of onclick, data-action, and form interception as appropriate
- Type definitions use snake_case field names matching actual API responses
- Type definitions verified against endpoint returns (not just handler structs)
- Type definition comments reference source files:
- Database rows:
// Matches internal/database/queries.sql.go:SearchMediaItemsRow - Handler structs:
// Matches handlers.BookInfo JSON: media_item_id, title, author
- Database rows:
import type { ... } from './types/api'pattern used for type checking- Full manual testing completed
- No regression in functionality
- Bundle sizes monitored and optimized
- SSR still works for all initial page loads
- All CRUD operations use existing
/api/*endpoints - Login template duplicate theme logic removed (lines 62-88)
- Search functionality uses correct types matching database SearchMediaItemsRow
- Analytics page loads data and displays charts correctly
- Queue management CRUD operations work (process, clear, filter)
- Conflict resolution works (individual and bulk operations)
- Documentation search functionality works
- All 10 missing type definitions added (analytics, queue, conflicts)
- Total converted: ~2,800 lines of inline script blocks
Next Steps
- Review and approve this plan
- Setup Phase 1: Convert
search.js→ TypeScript (ensure snake_case field names) - Begin Phase 2: Create core utility modules (including
types/api.d.tswith snake_case types) - Iterate through phases 3-5 (feature conversion)
- Phase 6: Final integration and cleanup (remove login.templ duplicate theme code, lines 62-88)
Appendix: Common Pitfalls
Database Rows vs Handler Structs
Problem: Some handler files define structs that don't match what the endpoint actually returns.
Example:
// internal/handlers/search.go
// This struct is DEFINED but never used!
type MediaItemSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Authors []SearchAuthor `json:"authors"` // Wrong! API returns string, not array
}
// internal/handlers/media.go
// The endpoint actually returns database rows:
func (mh *MediaHandler) SearchMediaItems(c echo.Context) error {
partialResults, err := mh.db.SearchMediaItems(...) // Returns []SearchMediaItemsRow
return c.JSON(http.StatusOK, partialResults) // Has library_id, author (string), etc.
}
Solution:
- Grep the endpoint function to find what it actually returns
- Check the return statement:
return c.JSON(..., variable) - Find that variable's type definition
- Use that type's JSON tags, not some unrelated handler struct
Verification:
# Find endpoint implementation
rg "func.*SearchMediaItems" internal/handlers/
# Check what it returns
rg -A 5 "return c.JSON" internal/handlers/media.go
# Find the returned type
rg "type SearchMediaItemsRow" internal/database/
Appendix: Progressive Enhancement Test Checklist
Use this checklist when converting templates to ensure progressive enhancement is maintained:
Before removing inline JavaScript:
- Identify all forms in the template
- Verify each form has
actionandmethodattributes - Verify each form has a submit button (not just button with onclick)
- Test form submission works with JavaScript disabled
- Identify all critical user flows (login, create, update, delete)
- Verify each critical flow has a non-JavaScript fallback
After TypeScript conversion:
- Test with JavaScript disabled (DevTools → Disable JavaScript)
- Verify forms still submit with full page reload
- Verify navigation links work (full page reload)
- Verify all data is visible on initial load (SSR)
- Test critical user flows work without JavaScript
- Enable JavaScript and verify enhanced experience
What to test without JavaScript:
- Page loads and displays data
- Forms can be submitted
- Navigation links work
- No console errors
What requires JavaScript (expected to fail without JS):
- Toast notifications
- AJAX form submission (no page reload)
- Modal dialogs
- Dynamic content updates
- Keyboard navigation shortcuts
- Auto-complete search
Appendix: Template Integration Examples
Before: Inline JavaScript
<!-- templates/collection_rules.templ -->
<script>
let collectionId = '{ collection.ID }';
function renderRules(rules) {
const container = document.getElementById('rules-container');
container.innerHTML = rules.map((rule, index) => `
<div class="rule-item">
<span>${rule.field}</span>
<button onclick="deleteRule('${rule.id}')">Delete</button>
</div>
`).join('');
}
function deleteRule(ruleId) {
fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
}).then(response => {
if (response.ok) {
loadRules();
showToast('Rule deleted');
}
});
}
function loadRules() {
fetch(`/api/collections/${collectionId}/rules`)
.then(response => response.json())
.then(rules => renderRules(rules));
}
loadRules();
</script>
After: TypeScript Module (Browser Globals)
TypeScript Module:
// web/src/collections.ts
interface Rule {
id: string;
field: string;
operator: string;
value: string;
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function loadRules(collectionId: string): Promise<void> {
try {
const response = await (window as any).api.get(`/collections/${collectionId}/rules`);
if (!response.ok) {
throw new Error('Failed to load rules');
}
const rules: Rule[] = await response.json();
renderRules(rules);
} catch (error) {
console.error('Load rules error:', error);
(window as any).showToast.error('Failed to load rules');
}
}
function renderRules(collectionId: string, rules: Rule[]): void {
const container = document.getElementById('rules-container');
if (!container) return;
container.innerHTML = rules.map(rule => `
<div class="rule-item" data-rule-id="${rule.id}">
<span>${escapeHtml(rule.field)}</span>
<button onclick="window.deleteRule('${collectionId}', '${rule.id}')">Delete</button>
</div>
`).join('');
}
async function loadRules(collectionId: string): Promise<void> {
try {
const response = await (window as any).api.get(`/collections/${collectionId}/rules`);
if (!response.ok) {
throw new Error('Failed to load rules');
}
const rules: Rule[] = await response.json();
renderRules(collectionId, rules);
} catch (error) {
console.error('Load rules error:', error);
(window as any).showToast.error('Failed to load rules');
}
}
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
if (!confirm('Are you sure you want to delete this rule?')) {
return;
}
try {
const response = await (window as any).api.delete(`/collections/${collectionId}/rules/${ruleId}`);
if (!response.ok) {
throw new Error('Failed to delete rule');
}
(window as any).showToast.success('Rule deleted');
const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`);
ruleElement?.remove();
} catch (error) {
console.error('Delete rule error:', error);
(window as any).showToast.error('Failed to delete rule');
}
}
// Export to window for HTML access
(window as any).loadRules = loadRules;
(window as any).deleteRule = deleteRule;
// Initialize on page load
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCollectionRules);
} else {
initializeCollectionRules();
}
}
function initializeCollectionRules() {
const collectionId = document.body.dataset.collectionId;
if (collectionId) {
loadRules(collectionId);
}
}
Cleaned Template:
<!-- templates/collection_rules.templ -->
<!DOCTYPE html>
<html>
<head>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/collections.js"></script>
</head>
<body data-collection-id="{ collection.ID }">
<div id="rules-container">
<!-- SSR: Initial rules rendered server-side -->
{ range .Rules }
<div class="rule-item" data-rule-id="{ .ID }">
<span>{ .Field }</span>
<button onclick="window.deleteRule('{collection.ID}', '{.ID}')">
Delete
</button>
</div>
{ end }
</div>
</body>
</html>
Benefits:
- ✅ No inline JavaScript logic (functions, fetch calls, state management)
- ✅ Simple onclick calls are explicit and easy to understand
- ✅ Type-safe API calls through procedural api module
- ✅ Better error handling with toast integration
- ✅ Reusable module attached to window
- ✅ SSR still works (initial rules in HTML)
- ✅ Progressive enhancement (form can submit without JS)
- ✅ No ES modules or bundler needed (browser globals)
Generated: 2025-02-17 Updated: 2025-02-18 (Revised to match existing codebase patterns) Updated: 2025-02-18 (Fixed file paths, removed unnecessary build scripts, added manual type sync strategy) Updated: 2025-02-18 (Changed to .d.ts for type definitions, added build verification steps, documented progressive enhancement pattern) Updated: 2025-02-18 (Corrected type definitions to use snake_case matching Go JSON tags, simplified progressive enhancement section, added missing types) Updated: 2025-02-18 (Clarified database row vs handler struct confusion, documented template-handler type sharing, added HTMX progressive enhancement details, added common pitfalls appendix) Updated: 2025-02-18 (Added missing templates: analytics, queue, conflicts, docs; added 10 missing type definitions; updated line counts to ~6,300; revised timeline to 20-25 days; added all missing phases and tasks) Updated: 2025-02-18 (VERIFIED CORRECTIONS: Fixed line counts (~2,800 actual vs ~6,300 estimated), corrected analytics/queue/conflicts type definitions to match actual Go structs, noted HTMX forms lack action attributes, reduced timeline to 13-18 days) Follows: PROJECT_GUIDELINES.md Architecture: Hybrid SSR + TypeScript CRUD (Browser Globals, No ES Modules)