Files
bookhoard/TYPESCRIPT_CONVERSION_PLAN.md
T

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 window object 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.Numeric don'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 matching BookInfo interface
  • 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 type statement is erased (no runtime import)
  • Output JavaScript has no require() or import statements
  • web/src/types/api.d.ts produces no .js file (.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:

  1. Convert web/static/search.jsweb/src/search.ts
  2. Add proper types for search results (use MediaItemSummary with snake_case fields)
  3. Extract keyboard navigation logic into pure functions
  4. Update templates to use new TypeScript module

Important: Use snake_case field names matching JSON responses:

  • item.id (not item.ID)
  • item.title (not item.Title)
  • item.author (single string, not item.Authors array)
  • item.library_id (not item.LibraryID)
  • item.library_type_name (not item.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:ts to verify compilation
  • Confirm web/static/search.js is 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:

  1. 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.go for database rows
      • internal/handlers/*.go for handler-defined structs
    • Critical: Use snake_case field names matching Go JSON tags
    • Example: json:"media_item_id"media_item_id: string
    • Use .d.ts extension (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
  2. Create web/src/storage.ts

    • localStorage wrapper with type safety
    • Token management helpers
    • Theme persistence
  3. Create web/src/dom.ts

    • escapeHtml utility
    • querySelector wrappers with null checks
    • Element creation helpers
  4. Create web/src/api.ts

    • API client functions (procedural, no classes)
    • Automatic auth header injection
    • Error handling integration with toast system
  5. 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:ts to verify all modules compile
  • Confirm .js files generated in web/static/
  • Verify types/api.d.ts produces no .js file
  • 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:ts to verify compilation
  • Confirm web/static/admin.js and web/static/analytics.js are 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}/rules endpoints
  • 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.ts already 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/process
  • clearFailedItems() - DELETE to /api/queue/failed
  • clearAllItems() - DELETE to /api/queue/all
  • filterQueue() - client-side filtering by status, type, device
  • refreshQueue() - 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:ts to verify compilation
  • Confirm .js files 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 status
  • dismissAllResolved() - POST to /api/conflicts/dismiss-resolved
  • bulkResolve() - POST to /api/conflicts/bulk-resolve with strategy (most_recent, highest_progress)
  • bulkDismiss() - POST to /api/conflicts/bulk-dismiss
  • handleResolveConflict() - 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 management
  • updateBulkActions() - update UI based on selected conflicts
  • toggleAllConflicts() - 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:ts to verify compilation
  • Confirm .js files 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-action attributes 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, changeTheme functions)
  • 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.json with new file structure (if needed)
  • Verify all .ts files compile to .js in web/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:ts to verify entire project compiles
  • Confirm all .js files generated in web/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):

  1. Add action attributes to HTMX forms for true progressive enhancement
  2. Accept that login/register flows require JavaScript (current state)
  3. 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 action and method attributes
  • 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('&lt;script&gt;');
    });
});

// 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:

  1. Open DevTools
  2. Settings → Disable JavaScript
  3. Reload page
  4. 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 action and method attributes
  • 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 tsc setup, 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:

  1. Git revert TypeScript files
  2. Inline JavaScript still works (wasn't deleted yet)
  3. Fix issues and retry
  4. 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
  • 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

  1. Review and approve this plan
  2. Setup Phase 1: Convert search.js → TypeScript (ensure snake_case field names)
  3. Begin Phase 2: Create core utility modules (including types/api.d.ts with snake_case types)
  4. Iterate through phases 3-5 (feature conversion)
  5. 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:

  1. Grep the endpoint function to find what it actually returns
  2. Check the return statement: return c.JSON(..., variable)
  3. Find that variable's type definition
  4. 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 action and method attributes
  • 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)