diff --git a/CLAUDE.md b/CLAUDE.md index b86bde7..ef9c994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,18 @@ When a form is submitted, the framework resolves the action in this order: - `live-preview/` — Tier 1 with `Change()` method for live updates - `chat/` — Tier 1+2 (uses `lvt-scroll` for auto-scroll) +## Framework Documentation + +Before writing code, always consult the LiveTemplate reference docs and guides: + +- **References:** `https://github.com/livetemplate/livetemplate/tree/main/docs/references/` — client attributes, server API, action routing +- **Guides:** `https://github.com/livetemplate/livetemplate/tree/main/docs/guides/` — progressive complexity, patterns, best practices + +Use framework-native solutions instead of custom JavaScript. Common patterns: +- `input type="search"` has a browser-native clear button; the framework handles the `search` event automatically (no custom JS needed) +- The `Change()` method auto-wires input/change/search events on named form fields with 300ms debounce +- Use `hidden` HTML attribute for visibility toggling (not `style="display:none"`) + ## CSS All examples use [Pico CSS](https://picocss.com/docs) exclusively: @@ -76,11 +88,13 @@ All examples use [Pico CSS](https://picocss.com/docs) exclusively: - Use semantic HTML — Pico auto-styles: `
` (cards), `` (modals), `
` (accordions), ``, `
- - - - - -
Total{{.TotalCount}}
Completed{{.CompletedCount}}
Remaining{{.RemainingCount}}
- - - {{range .Todos}} - - - - - {{.Text}} - - - - - {{end}} -
- - - - + +
+ + +
+ + +
+ + +
+ + + ``` -**Pico CSS features used:** -- `
` - Responsive centered layout -- `
` - Card-style sections with automatic spacing -- `
` with `
` - Inline form controls -- `` - Clean, styled tables -- `
...", - "2": "1", - "3": "0", - "4": "1" -} -``` +### Database -This demonstrates LiveTemplate's bandwidth efficiency - subsequent updates contain only changed dynamic values, not the full HTML. +SQLite via [sqlc](https://sqlc.dev/)-generated queries. The `db/` directory contains generated code from `queries.sql`. Schema migrations run automatically on startup, including detection and recreation of outdated schemas. ## Testing -### WebSocket Integration Test - -```bash -go test -v -run TestWebSocketBasic -``` - -Tests: -- WebSocket connection establishment -- Add todo action -- Toggle completion action -- Response validation - ### Browser E2E Test ```bash go test -v -run TestTodosE2E ``` -Tests: -- Initial page load with Pico CSS -- WebSocket connectivity -- LiveTemplate wrapper preservation -- Semantic HTML structure - Requires Docker for Chrome headless testing. ## Development Notes - **Port**: Defaults to `:8080`, override with `PORT` environment variable -- **Endpoint**: `/` handles both WebSocket upgrades and HTTP POST requests -- **Template Path**: Reads from `examples/todos/todos.tmpl` -- **Client Library**: Serves via `internal/testing.ServeClientLibrary()` (dev only) -- **State Isolation**: Each WebSocket connection gets its own todo list -- **Session Management**: HTTP connections persist state via cookies -- **Pico CSS**: Loaded from CDN, no build step required - -## Why Pico CSS? - -- **Zero configuration** - Works with semantic HTML -- **No custom classes** - `
`, `
`, `` are pre-styled -- **Responsive** - Mobile-friendly by default -- **Dark mode** - Automatic theme switching -- **Accessible** - Proper ARIA roles and keyboard navigation -- **Minimal** - ~10KB gzipped - -Perfect for LiveTemplate demos where you want beautiful UIs without CSS complexity! +- **Database**: `todos.db` in the current directory (`:memory:` when `TEST_MODE=1`) +- **Client Library**: Served via `e2etest.ServeClientLibrary` in dev mode, CDN in production diff --git a/todos/db_manager.go b/todos/db_manager.go index 322ee83..8820904 100644 --- a/todos/db_manager.go +++ b/todos/db_manager.go @@ -42,8 +42,20 @@ func InitDB(dbPath string) (*db.Queries, error) { return queries, nil } -// runMigrations creates the database schema +// runMigrations creates the database schema, handling upgrades from older versions. func runMigrations(db *sql.DB) error { + // Check if the todos table exists with an outdated schema (missing user_id column). + // CREATE TABLE IF NOT EXISTS won't modify an existing table, so we must detect + // and migrate the old schema before ensuring the current one. + if needsMigration, err := hasOutdatedSchema(db); err != nil { + return fmt.Errorf("checking schema: %w", err) + } else if needsMigration { + log.Println("Detected outdated todos table (missing user_id column), adding column...") + if _, err := db.Exec(`ALTER TABLE todos ADD COLUMN user_id TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("adding user_id column: %w", err) + } + } + schema := ` CREATE TABLE IF NOT EXISTS todos ( id TEXT PRIMARY KEY, @@ -61,6 +73,33 @@ CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id); return err } +// hasOutdatedSchema returns true if the todos table exists but lacks the user_id column. +func hasOutdatedSchema(db *sql.DB) (bool, error) { + rows, err := db.Query("PRAGMA table_info(todos)") + if err != nil { + return false, err + } + defer rows.Close() + + var hasTable, hasUserID bool + for rows.Next() { + hasTable = true + var cid int + var name, ctype string + var notnull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err != nil { + return false, err + } + if name == "user_id" { + hasUserID = true + } + } + + return hasTable && !hasUserID, rows.Err() +} + // CloseDB closes the database connection func CloseDB() { if database != nil { diff --git a/todos/todos.tmpl b/todos/todos.tmpl index 9d6ca39..55a9f11 100644 --- a/todos/todos.tmpl +++ b/todos/todos.tmpl @@ -1,141 +1,157 @@ {{/* Template Partials */}} -{{define "add-form"}} - -
- - - -
- {{ if .lvt.HasError "text" }} - {{ .lvt.Error "text" }} - {{ end }} - -{{end}} - -{{define "stats"}} - -

- - Total: {{ .TotalCount }} • - Completed: {{ .CompletedCount }} • - Remaining: {{ .RemainingCount }} - -

-{{end}} - -{{define "search-form"}} -
- - - - -
-{{end}} - -{{define "sort-select"}} -
- - -
-{{end}} - -{{define "todo-item"}} - - - - - -{{end}} - -{{define "pagination"}} - + + + + +{{ end }} + +{{ define "pagination" }} + +{{ end }} {{/* Main Template */}} + @@ -167,20 +183,24 @@

{{ .Title }}

A simple todo app with real-time updates

- {{ if .Username }}

Logged in as: {{ .Username }}

{{ end }} + {{ if .Username }} +

+ Logged in as: {{ .Username }} +

+ {{ end }}
{{/* Add Todo Form */}} - {{template "add-form" .}} + {{ template "add-form" . }} {{/* Statistics */}} - {{template "stats" .}} + {{ template "stats" . }} {{/* Search */}} - {{template "search-form" .}} + {{ template "search-form" . }} {{/* Sort */}} - {{template "sort-select" .}} + {{ template "sort-select" . }} {{/* Todo List */}}
@@ -188,11 +208,14 @@
-
- +{{ define "add-form" }} + +
+ - - -
- {{ .Text }} - -
- - -
-
+
+ + + +
+
+ {{ if .Completed }}{{ .Text }}{{ else }}{{ .Text }}{{ end }} + +
+ + +
+
{{ range .PaginatedTodos }} - {{template "todo-item" .}} + {{ template "todo-item" . }} {{ end }}
-

+

{{ if ne .SearchQuery "" }} No todos found matching "{{ .SearchQuery }}" {{ else }} @@ -201,12 +224,14 @@

{{/* Pagination Controls */}} - {{template "pagination" .}} + {{ template "pagination" . }} {{ if gt .CompletedCount 0 }} - +
+ +
{{ end }} @@ -216,10 +241,10 @@
- {{if .lvt.DevMode}} - - {{else}} - - {{end}} + {{ if .lvt.DevMode }} + + {{ else }} + + {{ end }} diff --git a/todos/todos_test.go b/todos/todos_test.go index 049ea7d..f42afc5 100644 --- a/todos/todos_test.go +++ b/todos/todos_test.go @@ -436,14 +436,10 @@ func TestTodosE2E(t *testing.T) { Text string `json:"text"` } paginationState struct { - HiddenAttr bool `json:"hiddenAttr"` - Display string `json:"display"` - DataVisible string `json:"dataVisible"` + HiddenAttr bool `json:"hiddenAttr"` } paginationVisibleState struct { - HiddenAttr bool `json:"hiddenAttr"` - Display string `json:"display"` - DataVisible string `json:"dataVisible"` + HiddenAttr bool `json:"hiddenAttr"` } debugInfo string consoleLogs string @@ -536,15 +532,8 @@ func TestTodosE2E(t *testing.T) { chromedp.Evaluate(` (() => { const nav = document.querySelector('[data-pagination]'); - if (!nav) { - return { hiddenAttr: true, display: 'none', dataVisible: 'false' }; - } - const style = window.getComputedStyle(nav); - return { - hiddenAttr: nav.hasAttribute('hidden'), - display: style.display, - dataVisible: nav.dataset.visible || '' - }; + if (!nav) return { hiddenAttr: true }; + return { hiddenAttr: nav.hasAttribute('hidden') }; })(); `, &paginationState), chromedp.OuterHTML(`section`, &html, chromedp.ByQuery), @@ -594,11 +583,8 @@ func TestTodosE2E(t *testing.T) { if !strings.Contains(emptyState.Text, "No todos found matching \"NonExistent\"") { t.Errorf("Empty state text unexpected: %q", emptyState.Text) } - if paginationState.Display != "none" { - t.Errorf("Pagination controls should be hidden (display none) when no todos match search. State: %+v", paginationState) - } - if paginationState.DataVisible != "false" { - t.Errorf("Pagination data-visible should be 'false' when hidden. State: %+v", paginationState) + if !paginationState.HiddenAttr { + t.Errorf("Pagination should have hidden attribute when no todos match search. State: %+v", paginationState) } t.Log("✅ Empty search results handled correctly") @@ -619,26 +605,16 @@ func TestTodosE2E(t *testing.T) { chromedp.Evaluate(` (() => { const nav = document.querySelector('[data-pagination]'); - if (!nav) { - return { hiddenAttr: true, display: 'none', dataVisible: 'false' }; - } - const style = window.getComputedStyle(nav); - return { - hiddenAttr: nav.hasAttribute('hidden'), - display: style.display, - dataVisible: nav.dataset.visible || '' - }; + if (!nav) return { hiddenAttr: true }; + return { hiddenAttr: nav.hasAttribute('hidden') }; })(); `, &paginationVisibleState), ) if err != nil { t.Fatalf("Failed to verify pagination visibility after clearing search: %v", err) } - if paginationVisibleState.Display != "block" { - t.Errorf("Pagination controls should be visible (display block) after clearing search. State: %+v", paginationVisibleState) - } - if paginationVisibleState.DataVisible != "true" { - t.Errorf("Pagination data-visible should be 'true' after clearing search. State: %+v", paginationVisibleState) + if paginationVisibleState.HiddenAttr { + t.Errorf("Pagination should NOT have hidden attribute after clearing search. State: %+v", paginationVisibleState) } t.Log("✅ Search cleared successfully")