Full Stack con Go: App de Notas y Tareas con HTMX, Templ y SQLite
Backend

Full Stack con Go: App de Notas y Tareas con HTMX, Templ y SQLite

Construye una app completa de notas y todos en Go: notas markdown con tags, listas de tareas, búsqueda full-text, autenticación, HTMX y Templ.

Por Omar Flores
#golang #go #full-stack #architecture #best-practices #guide #tutorial #performance #security

Full Stack con Go: App de Notas y Tareas con HTMX, Templ y SQLite

Piensa en un cuaderno bien organizado. La portada, las páginas, las pestañas y el marcador son todos parte del mismo objeto — algo que cargas, abres y guardas. Una aplicación Go full stack tiene esa misma cualidad: un binario lleva el router, los templates, las reglas de negocio y la conexión a la base de datos. Abres un archivo para entender el sistema. Despliegas un artefacto.

Esta guía construye Noteflow — una aplicación de notas y tareas donde los usuarios pueden escribir notas en markdown con tags y fijado, gestionar listas de tareas con prioridades y fechas límite, buscar en todo de forma instantánea, y hacer todo esto sin recargar la página. El stack completo es Go: modernc.org/sqlite (sin CGO), Templ para templates type-safe, HTMX para reactividad dirigida por el servidor, Alpine.js para estado local de UI, y Tailwind CSS para los estilos.


Por Qué SQLite para Este Proyecto

PostgreSQL es la elección correcta para despliegues multi-servidor y alta concurrencia de escritura. SQLite es la elección correcta para todo lo demás: aplicaciones de servidor único, herramientas embebidas, entornos de desarrollo, y aplicaciones donde la simplicidad operacional importa más que el escalado horizontal.

modernc.org/sqlite es un puerto Go puro de SQLite — sin compilador C, sin CGO, sin dependencias de build. La base de datos es un solo archivo. El respaldo es cp noteflow.db noteflow.db.bak. Las migraciones son archivos SQL planos. La aplicación completa se despliega como un binario más un archivo de base de datos.

Para una app de notas que sirve a un usuario o un equipo pequeño, SQLite maneja cientos de escrituras por segundo y millones de lecturas. Es la herramienta correcta.


El Stack

CapaTecnologíaPor qué
RouterChiMiddleware composable, compatible con http.Handler
TemplatesTemplCompilados, type-safe, con soporte de IDE
ReactividadHTMXFragmentos HTML, sin serialización JSON
Estado clienteAlpine.jsDropdowns, input de tags, toggles locales
EstilosTailwind CSSUtility-first, modo oscuro integrado
Base de datosSQLite + modernc.org/sqliteCero dependencias, base de datos en un archivo
Authbcrypt + cookies segurasBasada en sesiones, simple y correcta
Markdowngithub.com/yuin/goldmarkRenderizado HTML seguro desde contenido del usuario
BúsquedaSQLite FTS5Búsqueda full-text sin Elasticsearch

Estructura del Proyecto

noteflow/
  cmd/
    server/
      main.go
  internal/
    config/
      config.go
    domain/
      note.go                 # Entidades Nota, Tag
      todo.go                 # Entidades TodoList, TodoItem
      user.go                 # Entidades User, Session
      errors.go
    auth/
      repository.go
      service.go
      middleware.go
    note/
      repository.go           # CRUD de notas + búsqueda FTS
      service.go              # Renderizado markdown, gestión de tags
    todo/
      repository.go           # CRUD de todos + items
      service.go
    handler/
      auth.go
      note.go
      todo.go
      search.go
    view/
      layout.templ            # Layout base, nav, modo oscuro
      auth.templ              # Login, registro
      note/
        list.templ            # Grid de notas con sección de fijadas
        detail.templ          # Vista de nota individual (markdown renderizado)
        form.templ            # Formulario crear/editar nota
        card.templ            # Componente tarjeta de nota
      todo/
        list.templ            # Listas de tareas con barras de progreso
        detail.templ          # Lista individual con checklist
        form.templ            # Formulario crear/editar lista
        item.templ            # Item individual con checkbox (parcial HTMX)
      search/
        results.templ         # Resultados de búsqueda unificados
      components/
        tag.templ             # Badge de tag, input de tags
        empty.templ           # Estados vacíos
        toast.templ           # Notificaciones toast
  migrations/
    001_users.up.sql
    001_users.down.sql
    002_notes.up.sql
    002_notes.down.sql
    003_todos.up.sql
    003_todos.down.sql
  static/
    css/output.css
    js/htmx.min.js
    js/alpine.min.js
  Makefile
  Dockerfile
  .air.toml
  go.mod

Cada subdirectorio en internal/view/ refleja un concepto de dominio. Un desarrollador que busca el template de la lista de notas abre internal/view/note/list.templ — sin buscar en un directorio templates/ plano.


Capa de Dominio

El dominio describe el problema, no la implementación. Las notas tienen contenido, tags y estado de fijado. Los todos tienen una lista de items, cada uno con su propio estado de completado.

// internal/domain/note.go
package domain

import (
	"time"

	"github.com/google/uuid"
)

type Note struct {
	ID        uuid.UUID
	UserID    uuid.UUID
	Title     string
	Content   string   // Fuente Markdown
	HTML      string   // HTML renderizado y saneado (no persistido)
	Tags      []Tag
	Pinned    bool
	WordCount int
	CreatedAt time.Time
	UpdatedAt time.Time
}

type Tag struct {
	ID    uuid.UUID
	Name  string
	Color string // Clase de color Tailwind, ej. "blue", "green"
	Count int    // Cuántas notas usan este tag (para la barra lateral)
}

func (n *Note) HasTag(tagName string) bool {
	for _, t := range n.Tags {
		if t.Name == tagName {
			return true
		}
	}
	return false
}

func (n *Note) Excerpt(chars int) string {
	if len(n.Content) <= chars {
		return n.Content
	}
	return n.Content[:chars] + "..."
}
// internal/domain/todo.go
package domain

import (
	"time"

	"github.com/google/uuid"
)

type TodoList struct {
	ID          uuid.UUID
	UserID      uuid.UUID
	Title       string
	Description string
	Items       []TodoItem
	Priority    ListPriority
	DueDate     *time.Time
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

type TodoItem struct {
	ID          uuid.UUID
	ListID      uuid.UUID
	Text        string
	Done        bool
	Position    int
	CompletedAt *time.Time
	CreatedAt   time.Time
}

type ListPriority int

const (
	ListPriorityNone   ListPriority = 0
	ListPriorityLow    ListPriority = 1
	ListPriorityMedium ListPriority = 2
	ListPriorityHigh   ListPriority = 3
)

func (p ListPriority) Label() string {
	switch p {
	case ListPriorityLow:
		return "Baja"
	case ListPriorityMedium:
		return "Media"
	case ListPriorityHigh:
		return "Alta"
	default:
		return "Sin prioridad"
	}
}

func (p ListPriority) BadgeClass() string {
	switch p {
	case ListPriorityLow:
		return "bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300"
	case ListPriorityMedium:
		return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
	case ListPriorityHigh:
		return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300"
	default:
		return "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
	}
}

func (l *TodoList) Progress() int {
	if len(l.Items) == 0 {
		return 0
	}
	done := 0
	for _, item := range l.Items {
		if item.Done {
			done++
		}
	}
	return (done * 100) / len(l.Items)
}

func (l *TodoList) IsOverdue() bool {
	if l.DueDate == nil {
		return false
	}
	return time.Now().After(*l.DueDate) && l.Progress() < 100
}

func (l *TodoList) DoneCount() int {
	count := 0
	for _, item := range l.Items {
		if item.Done {
			count++
		}
	}
	return count
}
// internal/domain/errors.go
package domain

import "errors"

var (
	ErrNotFound       = errors.New("no encontrado")
	ErrUnauthorized   = errors.New("no autorizado")
	ErrDuplicateEmail = errors.New("correo ya registrado")
	ErrInvalidInput   = errors.New("entrada inválida")
)

Base de Datos: Migraciones SQLite

Tres archivos de migración configuran el esquema. La extensión FTS5 de SQLite impulsa la búsqueda full-text sin ningún motor de búsqueda externo.

-- migrations/001_users.up.sql
CREATE TABLE users (
    id TEXT PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    display_name TEXT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE sessions (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    expires_at DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- migrations/002_notes.up.sql
CREATE TABLE notes (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    content TEXT NOT NULL DEFAULT '',
    pinned INTEGER NOT NULL DEFAULT 0,
    word_count INTEGER NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT (datetime('now')),
    updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE tags (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    color TEXT NOT NULL DEFAULT 'blue',
    UNIQUE(user_id, name)
);

CREATE TABLE note_tags (
    note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (note_id, tag_id)
);

-- Índice de búsqueda full-text
CREATE VIRTUAL TABLE notes_fts USING fts5(
    title,
    content,
    content=notes,
    content_rowid=rowid
);

-- Mantener FTS sincronizado
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
    INSERT INTO notes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;

CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN
    INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
    INSERT INTO notes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;

CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
    INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
END;

CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_updated ON notes(updated_at DESC);
CREATE INDEX idx_note_tags_note ON note_tags(note_id);
CREATE INDEX idx_note_tags_tag ON note_tags(tag_id);
-- migrations/003_todos.up.sql
CREATE TABLE todo_lists (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    priority INTEGER NOT NULL DEFAULT 0,
    due_date DATETIME,
    created_at DATETIME NOT NULL DEFAULT (datetime('now')),
    updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE todo_items (
    id TEXT PRIMARY KEY,
    list_id TEXT NOT NULL REFERENCES todo_lists(id) ON DELETE CASCADE,
    text TEXT NOT NULL,
    done INTEGER NOT NULL DEFAULT 0,
    position INTEGER NOT NULL DEFAULT 0,
    completed_at DATETIME,
    created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE VIRTUAL TABLE todos_fts USING fts5(
    title,
    description,
    content=todo_lists,
    content_rowid=rowid
);

CREATE TRIGGER todos_fts_insert AFTER INSERT ON todo_lists BEGIN
    INSERT INTO todos_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER todos_fts_update AFTER UPDATE ON todo_lists BEGIN
    INSERT INTO todos_fts(todos_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
    INSERT INTO todos_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER todos_fts_delete AFTER DELETE ON todo_lists BEGIN
    INSERT INTO todos_fts(todos_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
END;

CREATE INDEX idx_todo_lists_user ON todo_lists(user_id);
CREATE INDEX idx_todo_items_list ON todo_items(list_id, position);

Los triggers FTS5 mantienen el índice de búsqueda sincronizado automáticamente. Cada insert, update y delete en notes o todo_lists actualiza la tabla virtual. Sin jobs en segundo plano, sin consistencia eventual — el índice de búsqueda siempre está actualizado.


Configuración de SQLite con modernc.org/sqlite

// internal/db/db.go
package db

import (
	"database/sql"
	"fmt"

	_ "modernc.org/sqlite"
)

func Open(path string) (*sql.DB, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, fmt.Errorf("abrir sqlite: %w", err)
	}

	pragmas := []string{
		"PRAGMA journal_mode=WAL",      // Write-Ahead Logging para concurrencia
		"PRAGMA synchronous=NORMAL",    // Fsync solo en checkpoints
		"PRAGMA foreign_keys=ON",       // Forzar restricciones FK
		"PRAGMA cache_size=-64000",     // Caché de 64MB
		"PRAGMA temp_store=MEMORY",     // Tablas temporales en memoria
		"PRAGMA busy_timeout=5000",     // Esperar 5s antes de SQLITE_BUSY
	}

	for _, pragma := range pragmas {
		if _, err := db.Exec(pragma); err != nil {
			return nil, fmt.Errorf("configurar pragma %s: %w", pragma, err)
		}
	}

	db.SetMaxOpenConns(1)  // Una sola escritura previene SQLITE_BUSY
	db.SetMaxIdleConns(1)

	return db, nil
}

Los PRAGMAs importan. El modo WAL permite lecturas concurrentes mientras ocurre una escritura — crítico para una aplicación web. busy_timeout=5000 hace que el driver espere hasta 5 segundos por un bloqueo en vez de retornar inmediatamente SQLITE_BUSY. SetMaxOpenConns(1) previene conflictos de escritura a nivel de la aplicación.


Repositorio de Notas

// internal/note/repository.go
package note

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"

	"noteflow/internal/domain"
)

type Repository struct {
	db *sql.DB
}

func NewRepository(db *sql.DB) *Repository {
	return &Repository{db: db}
}

func (r *Repository) Create(ctx context.Context, note *domain.Note) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("iniciar tx: %w", err)
	}
	defer tx.Rollback()

	_, err = tx.ExecContext(ctx,
		`INSERT INTO notes (id, user_id, title, content, pinned, word_count, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		note.ID.String(), note.UserID.String(), note.Title, note.Content,
		note.Pinned, note.WordCount, note.CreatedAt, note.UpdatedAt,
	)
	if err != nil {
		return fmt.Errorf("insertar nota: %w", err)
	}

	if err := r.syncTags(ctx, tx, note.ID, note.UserID, note.Tags); err != nil {
		return err
	}

	return tx.Commit()
}

func (r *Repository) Update(ctx context.Context, note *domain.Note) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("iniciar tx: %w", err)
	}
	defer tx.Rollback()

	res, err := tx.ExecContext(ctx,
		`UPDATE notes SET title=?, content=?, pinned=?, word_count=?, updated_at=?
		 WHERE id=? AND user_id=?`,
		note.Title, note.Content, note.Pinned, note.WordCount, time.Now(),
		note.ID.String(), note.UserID.String(),
	)
	if err != nil {
		return fmt.Errorf("actualizar nota: %w", err)
	}

	rows, _ := res.RowsAffected()
	if rows == 0 {
		return domain.ErrNotFound
	}

	_, err = tx.ExecContext(ctx, `DELETE FROM note_tags WHERE note_id=?`, note.ID.String())
	if err != nil {
		return fmt.Errorf("eliminar tags anteriores: %w", err)
	}

	if err := r.syncTags(ctx, tx, note.ID, note.UserID, note.Tags); err != nil {
		return err
	}

	return tx.Commit()
}

func (r *Repository) GetByID(ctx context.Context, noteID, userID uuid.UUID) (*domain.Note, error) {
	note := &domain.Note{}
	var id, uid string
	var pinned int

	err := r.db.QueryRowContext(ctx,
		`SELECT id, user_id, title, content, pinned, word_count, created_at, updated_at
		 FROM notes WHERE id=? AND user_id=?`,
		noteID.String(), userID.String(),
	).Scan(&id, &uid, &note.Title, &note.Content, &pinned, &note.WordCount, &note.CreatedAt, &note.UpdatedAt)
	if err == sql.ErrNoRows {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("consultar nota: %w", err)
	}

	note.ID, _ = uuid.Parse(id)
	note.UserID, _ = uuid.Parse(uid)
	note.Pinned = pinned == 1

	tags, err := r.getNoteTags(ctx, note.ID)
	if err != nil {
		return nil, err
	}
	note.Tags = tags

	return note, nil
}

func (r *Repository) List(ctx context.Context, userID uuid.UUID, tagFilter string) ([]domain.Note, error) {
	query := `
		SELECT DISTINCT n.id, n.user_id, n.title, n.content, n.pinned, n.word_count, n.created_at, n.updated_at
		FROM notes n`

	args := []any{userID.String()}

	if tagFilter != "" {
		query += `
		JOIN note_tags nt ON n.id = nt.note_id
		JOIN tags t ON nt.tag_id = t.id AND t.name = ?`
		args = append([]any{tagFilter}, args...)
	}

	query += ` WHERE n.user_id = ? ORDER BY n.pinned DESC, n.updated_at DESC`

	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("listar notas: %w", err)
	}
	defer rows.Close()

	var notes []domain.Note
	for rows.Next() {
		var note domain.Note
		var id, uid string
		var pinned int

		if err := rows.Scan(&id, &uid, &note.Title, &note.Content, &pinned, &note.WordCount, &note.CreatedAt, &note.UpdatedAt); err != nil {
			return nil, fmt.Errorf("escanear nota: %w", err)
		}

		note.ID, _ = uuid.Parse(id)
		note.UserID, _ = uuid.Parse(uid)
		note.Pinned = pinned == 1
		notes = append(notes, note)
	}

	if len(notes) > 0 {
		if err := r.loadTagsForNotes(ctx, notes); err != nil {
			return nil, err
		}
	}

	return notes, nil
}

func (r *Repository) Search(ctx context.Context, userID uuid.UUID, query string) ([]domain.Note, error) {
	safeQuery := strings.ReplaceAll(query, `"`, `""`)
	ftsQuery := fmt.Sprintf(`"%s"*`, safeQuery)

	rows, err := r.db.QueryContext(ctx,
		`SELECT n.id, n.user_id, n.title, n.content, n.pinned, n.word_count, n.created_at, n.updated_at,
		        highlight(notes_fts, 0, '<mark>', '</mark>') as title_hl,
		        snippet(notes_fts, 1, '<mark>', '</mark>', '...', 20) as snippet
		 FROM notes_fts
		 JOIN notes n ON notes_fts.rowid = n.rowid
		 WHERE notes_fts MATCH ? AND n.user_id = ?
		 ORDER BY rank
		 LIMIT 20`,
		ftsQuery, userID.String(),
	)
	if err != nil {
		return nil, fmt.Errorf("buscar notas: %w", err)
	}
	defer rows.Close()

	var notes []domain.Note
	for rows.Next() {
		var note domain.Note
		var id, uid string
		var pinned int
		var titleHL, snippet string

		if err := rows.Scan(&id, &uid, &note.Title, &note.Content, &pinned, &note.WordCount,
			&note.CreatedAt, &note.UpdatedAt, &titleHL, &snippet); err != nil {
			return nil, fmt.Errorf("escanear resultado: %w", err)
		}

		note.ID, _ = uuid.Parse(id)
		note.UserID, _ = uuid.Parse(uid)
		note.Pinned = pinned == 1
		note.HTML = fmt.Sprintf(`<span class="font-medium">%s</span><p class="text-sm text-gray-500 mt-1">%s</p>`, titleHL, snippet)
		notes = append(notes, note)
	}

	return notes, nil
}

func (r *Repository) TogglePin(ctx context.Context, noteID, userID uuid.UUID) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE notes SET pinned = NOT pinned, updated_at = datetime('now')
		 WHERE id = ? AND user_id = ?`,
		noteID.String(), userID.String(),
	)
	return err
}

func (r *Repository) Delete(ctx context.Context, noteID, userID uuid.UUID) error {
	res, err := r.db.ExecContext(ctx,
		`DELETE FROM notes WHERE id = ? AND user_id = ?`,
		noteID.String(), userID.String(),
	)
	if err != nil {
		return fmt.Errorf("eliminar nota: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows == 0 {
		return domain.ErrNotFound
	}
	return nil
}

func (r *Repository) GetUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT t.id, t.name, t.color, COUNT(nt.note_id) as count
		 FROM tags t
		 LEFT JOIN note_tags nt ON t.id = nt.tag_id
		 WHERE t.user_id = ?
		 GROUP BY t.id
		 ORDER BY count DESC, t.name`,
		userID.String(),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var tags []domain.Tag
	for rows.Next() {
		var tag domain.Tag
		var id string
		if err := rows.Scan(&id, &tag.Name, &tag.Color, &tag.Count); err != nil {
			return nil, err
		}
		tag.ID, _ = uuid.Parse(id)
		tags = append(tags, tag)
	}
	return tags, nil
}

func (r *Repository) syncTags(ctx context.Context, tx *sql.Tx, noteID, userID uuid.UUID, tags []domain.Tag) error {
	for _, tag := range tags {
		tagID := uuid.New()
		_, err := tx.ExecContext(ctx,
			`INSERT INTO tags (id, user_id, name, color) VALUES (?, ?, ?, ?)
			 ON CONFLICT(user_id, name) DO UPDATE SET color=excluded.color`,
			tagID.String(), userID.String(), tag.Name, tag.Color,
		)
		if err != nil {
			return fmt.Errorf("upsert tag %s: %w", tag.Name, err)
		}

		var actualID string
		err = tx.QueryRowContext(ctx,
			`SELECT id FROM tags WHERE user_id=? AND name=?`,
			userID.String(), tag.Name,
		).Scan(&actualID)
		if err != nil {
			return fmt.Errorf("obtener id de tag: %w", err)
		}

		_, err = tx.ExecContext(ctx,
			`INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)`,
			noteID.String(), actualID,
		)
		if err != nil {
			return fmt.Errorf("vincular tag: %w", err)
		}
	}
	return nil
}

func (r *Repository) getNoteTags(ctx context.Context, noteID uuid.UUID) ([]domain.Tag, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT t.id, t.name, t.color FROM tags t
		 JOIN note_tags nt ON t.id = nt.tag_id
		 WHERE nt.note_id = ? ORDER BY t.name`,
		noteID.String(),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var tags []domain.Tag
	for rows.Next() {
		var tag domain.Tag
		var id string
		if err := rows.Scan(&id, &tag.Name, &tag.Color); err != nil {
			return nil, err
		}
		tag.ID, _ = uuid.Parse(id)
		tags = append(tags, tag)
	}
	return tags, nil
}

func (r *Repository) loadTagsForNotes(ctx context.Context, notes []domain.Note) error {
	ids := make([]string, len(notes))
	for i, n := range notes {
		ids[i] = "'" + n.ID.String() + "'"
	}

	rows, err := r.db.QueryContext(ctx,
		fmt.Sprintf(
			`SELECT nt.note_id, t.id, t.name, t.color FROM tags t
			 JOIN note_tags nt ON t.id = nt.tag_id
			 WHERE nt.note_id IN (%s) ORDER BY t.name`,
			strings.Join(ids, ","),
		),
	)
	if err != nil {
		return err
	}
	defer rows.Close()

	tagMap := make(map[string][]domain.Tag)
	for rows.Next() {
		var noteID, tagID, name, color string
		if err := rows.Scan(&noteID, &tagID, &name, &color); err != nil {
			return err
		}
		id, _ := uuid.Parse(tagID)
		tagMap[noteID] = append(tagMap[noteID], domain.Tag{ID: id, Name: name, Color: color})
	}

	for i := range notes {
		notes[i].Tags = tagMap[notes[i].ID.String()]
	}
	return nil
}

El método Search usa las funciones highlight() y snippet() de FTS5 para devolver resultados con los términos coincidentes ya envueltos en etiquetas <mark>. La aplicación no necesita post-procesar el texto — SQLite lo maneja.


Servicio de Notas: Renderizado Markdown

La capa de servicio posee el renderizado markdown. Cuando se obtiene una nota, el servicio convierte la fuente markdown almacenada en HTML saneado antes de que el template lo renderice.

// internal/note/service.go
package note

import (
	"bytes"
	"context"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"

	"noteflow/internal/domain"
)

var md = goldmark.New(
	goldmark.WithExtensions(
		extension.GFM,         // GitHub Flavored Markdown: tablas, tachado, listas de tareas
		extension.Typographer, // Comillas inteligentes, guiones
	),
	goldmark.WithParserOptions(
		parser.WithAutoHeadingID(), // Links de anclaje en encabezados
	),
	goldmark.WithRendererOptions(
		html.WithHardWraps(),
		html.WithXHTML(),
	),
)

type Service struct {
	repo *Repository
}

func NewService(repo *Repository) *Service {
	return &Service{repo: repo}
}

func (s *Service) CreateNote(ctx context.Context, userID uuid.UUID, title, content string, tagNames []string) (*domain.Note, error) {
	tags := normalizeTagNames(tagNames)

	note := &domain.Note{
		ID:        uuid.New(),
		UserID:    userID,
		Title:     strings.TrimSpace(title),
		Content:   content,
		Tags:      tags,
		WordCount: countWords(content),
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	if err := s.repo.Create(ctx, note); err != nil {
		return nil, err
	}

	note.HTML = RenderMarkdown(note.Content)
	return note, nil
}

func (s *Service) GetNote(ctx context.Context, noteID, userID uuid.UUID) (*domain.Note, error) {
	note, err := s.repo.GetByID(ctx, noteID, userID)
	if err != nil {
		return nil, err
	}
	note.HTML = RenderMarkdown(note.Content)
	return note, nil
}

func (s *Service) ListNotes(ctx context.Context, userID uuid.UUID, tagFilter string) ([]domain.Note, error) {
	return s.repo.List(ctx, userID, tagFilter)
}

func (s *Service) UpdateNote(ctx context.Context, noteID, userID uuid.UUID, title, content string, tagNames []string, pinned bool) (*domain.Note, error) {
	note := &domain.Note{
		ID:        noteID,
		UserID:    userID,
		Title:     strings.TrimSpace(title),
		Content:   content,
		Tags:      normalizeTagNames(tagNames),
		Pinned:    pinned,
		WordCount: countWords(content),
		UpdatedAt: time.Now(),
	}

	if err := s.repo.Update(ctx, note); err != nil {
		return nil, err
	}

	note.HTML = RenderMarkdown(note.Content)
	return note, nil
}

func (s *Service) GetUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) {
	return s.repo.GetUserTags(ctx, userID)
}

func (s *Service) TogglePin(ctx context.Context, noteID, userID uuid.UUID) error {
	return s.repo.TogglePin(ctx, noteID, userID)
}

func (s *Service) Delete(ctx context.Context, noteID, userID uuid.UUID) error {
	return s.repo.Delete(ctx, noteID, userID)
}

func (s *Service) SearchNotes(ctx context.Context, userID uuid.UUID, query string) ([]domain.Note, error) {
	return s.repo.Search(ctx, userID, query)
}

func RenderMarkdown(source string) string {
	var buf bytes.Buffer
	if err := md.Convert([]byte(source), &buf); err != nil {
		return "<p>Error al renderizar contenido</p>"
	}
	return buf.String()
}

func countWords(s string) int {
	return len(strings.Fields(s))
}

func normalizeTagNames(names []string) []domain.Tag {
	colors := []string{"blue", "green", "purple", "orange", "pink", "teal", "red", "yellow"}
	var tags []domain.Tag
	seen := make(map[string]bool)

	for i, name := range names {
		name = strings.ToLower(strings.TrimSpace(name))
		if name == "" || seen[name] {
			continue
		}
		seen[name] = true
		tags = append(tags, domain.Tag{
			Name:  name,
			Color: colors[i%len(colors)],
		})
	}
	return tags
}

Goldmark renderiza GitHub Flavored Markdown. Los usuarios obtienen tablas, tachado, listas de tareas (checkboxes en markdown renderizado) y code fencing con clases de sintaxis. La opción WithAutoHeadingID agrega links de anclaje a los encabezados para que los usuarios puedan enlazar a secciones dentro de una nota.


Templates Templ

Layout Base con Barra Lateral

// internal/view/layout.templ
package view

import "noteflow/internal/domain"

templ Layout(title string, user *domain.User, tags []domain.Tag) {
	<!DOCTYPE html>
	<html lang="es" class="h-full"
	      x-data="{ dark: localStorage.getItem('dark') === 'true', sidebarOpen: true }"
	      x-init="$watch('dark', v => { localStorage.setItem('dark', v); document.documentElement.classList.toggle('dark', v) }); document.documentElement.classList.toggle('dark', dark)"
	      :class="{ 'dark': dark }">
	<head>
		<meta charset="UTF-8"/>
		<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
		<title>{ title } — Noteflow</title>
		<link rel="stylesheet" href="/static/css/output.css"/>
		<script src="/static/js/htmx.min.js" defer></script>
		<script src="/static/js/alpine.min.js" defer></script>
	</head>
	<body class="h-full flex bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
		<!-- Barra lateral -->
		<aside :class="sidebarOpen ? 'w-64' : 'w-14'"
		       class="flex-shrink-0 h-screen bg-white dark:bg-gray-900 border-r border-gray-200
		              dark:border-gray-800 flex flex-col transition-all duration-200 overflow-hidden">
			<div class="h-14 flex items-center px-4 border-b border-gray-100 dark:border-gray-800 gap-3">
				<button @click="sidebarOpen = !sidebarOpen"
				        class="w-6 h-6 flex flex-col gap-1.5 justify-center cursor-pointer">
					<span class="block h-0.5 bg-current"></span>
					<span class="block h-0.5 bg-current"></span>
					<span class="block h-0.5 bg-current"></span>
				</button>
				<span x-show="sidebarOpen" class="font-bold text-blue-600 dark:text-blue-400 text-lg">
					Noteflow
				</span>
			</div>

			<nav class="flex-1 px-2 py-4 space-y-1">
				@SidebarLink("/notes", "Notas", "note", title == "Notas")
				@SidebarLink("/todos", "Tareas", "todo", title == "Tareas")
				@SidebarLink("/search", "Buscar", "search", title == "Búsqueda")
			</nav>

			if len(tags) > 0 {
				<div x-show="sidebarOpen" class="px-3 pb-4">
					<p class="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">
						Tags
					</p>
					for _, tag := range tags {
						<a href={ templ.SafeURL("/notes?tag=" + tag.Name) }
						   class="flex items-center justify-between py-1 px-2 rounded text-sm
						          hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
							<span class="flex items-center gap-2">
								<span class={ "w-2 h-2 rounded-full bg-" + tag.Color + "-400" }></span>
								{ tag.Name }
							</span>
							<span class="text-xs text-gray-400">{ tag.Count }</span>
						</a>
					}
				</div>
			}

			<div class="h-14 border-t border-gray-100 dark:border-gray-800 px-3 flex items-center gap-3">
				<div class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center
				            text-sm font-bold flex-shrink-0">
					{ string([]rune(user.DisplayName)[0:1]) }
				</div>
				<span x-show="sidebarOpen" class="text-sm truncate flex-1">{ user.DisplayName }</span>
				<button x-show="sidebarOpen" @click="dark = !dark"
				        class="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-xs">
					<span x-show="!dark">Oscuro</span>
					<span x-show="dark">Claro</span>
				</button>
			</div>
		</aside>

		<!-- Contenido principal -->
		<div class="flex-1 flex flex-col min-w-0 h-screen overflow-hidden">
			<header class="h-14 flex items-center px-6 border-b border-gray-200 dark:border-gray-800
			               bg-white dark:bg-gray-900 gap-4 flex-shrink-0">
				<h1 class="font-semibold text-lg flex-1">{ title }</h1>
				<form hx-get="/search" hx-target="#content" hx-trigger="input changed delay:300ms"
				      class="relative">
					<input type="text" name="q" placeholder="Buscar..."
					       class="pl-8 pr-4 py-1.5 text-sm border border-gray-200 dark:border-gray-700
					              rounded-lg bg-gray-50 dark:bg-gray-800 focus:ring-2 focus:ring-blue-500
					              outline-none w-56"/>
				</form>
				<form hx-post="/logout">
					<button type="submit"
					        class="text-sm text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400">
						Salir
					</button>
				</form>
			</header>
			<main id="content" class="flex-1 overflow-y-auto p-6">
				{ children... }
			</main>
		</div>
	</body>
	</html>
}

templ SidebarLink(href, label, icon string, active bool) {
	<a href={ templ.SafeURL(href) }
	   class={ "flex items-center gap-3 px-2 py-2 rounded-lg text-sm transition-colors " +
	           sidebarLinkClass(active) }>
		<span class="w-5 h-5 flex-shrink-0 text-center">{ sidebarIcon(icon) }</span>
		<span x-show="sidebarOpen">{ label }</span>
	</a>
}

func sidebarLinkClass(active bool) string {
	if active {
		return "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 font-medium"
	}
	return "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
}

func sidebarIcon(icon string) string {
	switch icon {
	case "note":
		return "N"
	case "todo":
		return "T"
	case "search":
		return "B"
	default:
		return "?"
	}
}

Vista de Lista de Notas

// internal/view/note/list.templ
package note

import (
	"fmt"
	"noteflow/internal/domain"
	"noteflow/internal/view"
)

templ ListPage(user *domain.User, notes []domain.Note, tags []domain.Tag, activeTag string) {
	@view.Layout("Notas", user, tags) {
		<div class="max-w-5xl mx-auto">
			<div class="flex items-center justify-between mb-6">
				<div class="flex items-center gap-2">
					if activeTag != "" {
						<span class="text-sm text-gray-500">Filtrado por:</span>
						<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700
						            dark:text-blue-400 rounded-full text-sm font-medium">
							{ activeTag }
						</span>
						<a href="/notes" class="text-xs text-gray-400 hover:text-gray-600">Limpiar</a>
					}
					<span class="text-sm text-gray-400">
						{ fmt.Sprintf("%d notas", len(notes)) }
					</span>
				</div>
				<a href="/notes/new"
				   class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
					Nueva Nota
				</a>
			</div>

			if len(notes) == 0 {
				@view.EmptyState("Sin notas aún", "Crea tu primera nota para comenzar.")
			} else {
				@pinnedSection(notes)
				<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
					for _, n := range notes {
						if !n.Pinned {
							@NoteCard(n)
						}
					}
				</div>
			}
		</div>
	}
}

templ pinnedSection(notes []domain.Note) {
	for _, n := range notes {
		if n.Pinned {
			<div class="mb-6">
				<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3">Fijadas</p>
				<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
					for _, pinned := range notes {
						if pinned.Pinned {
							@NoteCard(pinned)
						}
					}
				</div>
			</div>
			break
		}
	}
}

templ NoteCard(n domain.Note) {
	<div class="group bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800
	            p-4 hover:shadow-md dark:hover:shadow-black/30 transition-shadow relative">
		if n.Pinned {
			<span class="absolute top-3 right-3 text-amber-400 text-xs">fijada</span>
		}
		<a href={ templ.SafeURL("/notes/" + n.ID.String()) } class="block">
			<h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate pr-6">
				{ n.Title }
			</h2>
			<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-3 leading-relaxed">
				{ n.Excerpt(160) }
			</p>
		</a>
		if len(n.Tags) > 0 {
			<div class="flex flex-wrap gap-1.5 mt-3">
				for _, tag := range n.Tags {
					@TagBadge(tag)
				}
			</div>
		}
		<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
			<span class="text-xs text-gray-400">
				{ fmt.Sprintf("%d palabras", n.WordCount) }
			</span>
			<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
				<button hx-post={ "/notes/" + n.ID.String() + "/pin" }
				        hx-target="closest div.group"
				        hx-swap="outerHTML"
				        class="text-xs text-gray-400 hover:text-amber-500">
					{ pinLabel(n.Pinned) }
				</button>
				<a href={ templ.SafeURL("/notes/" + n.ID.String() + "/edit") }
				   class="text-xs text-gray-400 hover:text-blue-600">Editar</a>
				<button hx-delete={ "/notes/" + n.ID.String() }
				        hx-confirm="¿Eliminar esta nota?"
				        hx-target="closest div.group"
				        hx-swap="outerHTML swap:0.2s"
				        class="text-xs text-gray-400 hover:text-red-500">
					Eliminar
				</button>
			</div>
		</div>
	</div>
}

templ TagBadge(tag domain.Tag) {
	<span class={ "px-2 py-0.5 rounded-full text-xs font-medium bg-" + tag.Color + "-100 text-" +
	             tag.Color + "-700 dark:bg-" + tag.Color + "-900/30 dark:text-" + tag.Color + "-400" }>
		{ tag.Name }
	</span>
}

func pinLabel(pinned bool) string {
	if pinned {
		return "Desfijar"
	}
	return "Fijar"
}

Vista de Lista de Tareas con HTMX Checklist

// internal/view/todo/list.templ
package todo

import (
	"fmt"
	"noteflow/internal/domain"
	"noteflow/internal/view"
	"github.com/google/uuid"
)

templ ListPage(user *domain.User, lists []domain.TodoList, tags []domain.Tag) {
	@view.Layout("Tareas", user, tags) {
		<div class="max-w-4xl mx-auto">
			<div class="flex justify-between items-center mb-6">
				<span class="text-sm text-gray-400">{ fmt.Sprintf("%d listas", len(lists)) }</span>
				<a href="/todos/new"
				   class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
					Nueva Lista
				</a>
			</div>

			if len(lists) == 0 {
				@view.EmptyState("Sin listas de tareas", "Crea tu primera lista para organizar tus tareas.")
			} else {
				<div class="space-y-4">
					for _, list := range lists {
						@TodoListCard(list)
					}
				</div>
			}
		</div>
	}
}

templ TodoListCard(list domain.TodoList) {
	<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5"
	     id={ "list-" + list.ID.String() }>
		<div class="flex items-start justify-between mb-4">
			<div>
				<a href={ templ.SafeURL("/todos/" + list.ID.String()) }
				   class="font-semibold text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400">
					{ list.Title }
				</a>
				if list.Description != "" {
					<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{ list.Description }</p>
				}
			</div>
			<div class="flex items-center gap-2 flex-shrink-0 ml-4">
				if list.Priority != domain.ListPriorityNone {
					<span class={ "text-xs px-2 py-0.5 rounded-full " + list.Priority.BadgeClass() }>
						{ list.Priority.Label() }
					</span>
				}
				if list.IsOverdue() {
					<span class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
						Vencida
					</span>
				}
			</div>
		</div>

		if len(list.Items) > 0 {
			<div class="mb-4">
				<div class="flex items-center justify-between text-xs text-gray-400 mb-1">
					<span>{ fmt.Sprintf("%d / %d completadas", list.DoneCount(), len(list.Items)) }</span>
					<span>{ fmt.Sprintf("%d%%", list.Progress()) }</span>
				</div>
				<div class="h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
					<div class={ progressBarClass(list.Progress()) }
					     style={ fmt.Sprintf("width: %d%%", list.Progress()) }></div>
				</div>
			</div>
		}

		<div class="space-y-2">
			for i, item := range list.Items {
				if i < 3 {
					@TodoItemRow(item, list.ID)
				}
			}
			if len(list.Items) > 3 {
				<a href={ templ.SafeURL("/todos/" + list.ID.String()) }
				   class="block text-xs text-gray-400 hover:text-blue-600 pt-1">
					{ fmt.Sprintf("+ %d más", len(list.Items) - 3) }
				</a>
			}
		</div>

		<form hx-post={ "/todos/" + list.ID.String() + "/items" }
		      hx-target={ "#list-" + list.ID.String() + " .space-y-2" }
		      hx-swap="beforeend"
		      @submit="this.reset()"
		      class="mt-4 flex gap-2">
			<input type="text" name="text" placeholder="Agregar tarea..."
			       required
			       class="flex-1 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700
			              rounded-lg bg-gray-50 dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"/>
			<button type="submit"
			        class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg">
				Agregar
			</button>
		</form>
	</div>
}

templ TodoItemRow(item domain.TodoItem, listID uuid.UUID) {
	<div class="flex items-start gap-3 group" id={ "item-" + item.ID.String() }>
		<button hx-post={ "/todos/items/" + item.ID.String() + "/toggle" }
		        hx-target={ "#item-" + item.ID.String() }
		        hx-swap="outerHTML"
		        class={ "w-4 h-4 mt-0.5 rounded border-2 flex-shrink-0 transition-colors " + checkboxClass(item.Done) }>
		</button>
		<span class={ "text-sm flex-1 " + itemTextClass(item.Done) }>
			{ item.Text }
		</span>
		<button hx-delete={ "/todos/items/" + item.ID.String() }
		        hx-target={ "#item-" + item.ID.String() }
		        hx-swap="outerHTML swap:0.15s"
		        class="text-xs text-gray-300 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity">
			x
		</button>
	</div>
}

func checkboxClass(done bool) string {
	if done {
		return "border-blue-600 bg-blue-600 text-white"
	}
	return "border-gray-300 dark:border-gray-600 hover:border-blue-500"
}

func itemTextClass(done bool) string {
	if done {
		return "line-through text-gray-400 dark:text-gray-500"
	}
	return "text-gray-700 dark:text-gray-300"
}

func progressBarClass(progress int) string {
	if progress == 100 {
		return "h-full bg-green-500 rounded-full transition-all"
	}
	if progress >= 50 {
		return "h-full bg-blue-500 rounded-full transition-all"
	}
	return "h-full bg-gray-400 rounded-full transition-all"
}

La interacción del checklist es el ejemplo más limpio del modelo HTMX. El botón checkbox envía un POST a /todos/items/{id}/toggle. El servidor alterna el item en la base de datos, renderiza el parcial TodoItemRow con el nuevo estado y lo devuelve. HTMX reemplaza la fila existente con el HTML fresco. La actualización de UI y la actualización de base de datos ocurren en el mismo round-trip, sin gestión de estado en JavaScript.


Handler de Búsqueda

// internal/handler/search.go
package handler

import (
	"net/http"

	"noteflow/internal/auth"
	"noteflow/internal/note"
	"noteflow/internal/todo"
	viewsearch "noteflow/internal/view/search"
)

type SearchHandler struct {
	noteService *note.Service
	todoService *todo.Service
}

func NewSearchHandler(noteService *note.Service, todoService *todo.Service) *SearchHandler {
	return &SearchHandler{noteService: noteService, todoService: todoService}
}

func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	query := r.URL.Query().Get("q")

	if query == "" {
		w.WriteHeader(http.StatusOK)
		return
	}

	notes, _ := h.noteService.SearchNotes(r.Context(), user.ID, query)
	todos, _ := h.todoService.SearchLists(r.Context(), user.ID, query)

	// Si la petición es de HTMX, devolver solo el fragmento de resultados
	if r.Header.Get("HX-Request") == "true" {
		viewsearch.ResultsFragment(notes, todos, query).Render(r.Context(), w)
		return
	}

	// Página completa para navegación directa
	tags, _ := h.noteService.GetUserTags(r.Context(), user.ID)
	viewsearch.SearchPage(user, tags, notes, todos, query).Render(r.Context(), w)
}

La cabecera HX-Request la establece HTMX en cada petición que hace. El handler de búsqueda verifica esta cabecera para decidir si renderiza una página completa (para navegación directa) o solo el fragmento de resultados (para búsquedas disparadas por HTMX). Este es el patrón estándar de mejora progresiva: el mismo endpoint sirve tanto actualizaciones parciales HTMX como navegación directa del navegador.


Router Principal

// cmd/server/main.go
package main

import (
	"context"
	"database/sql"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/sqlite"
	_ "github.com/golang-migrate/migrate/v4/source/file"
	_ "modernc.org/sqlite"

	"noteflow/internal/auth"
	"noteflow/internal/config"
	"noteflow/internal/db"
	"noteflow/internal/handler"
	"noteflow/internal/note"
	"noteflow/internal/todo"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
	slog.SetDefault(logger)

	cfg, err := config.Load()
	if err != nil {
		slog.Error("error de configuración", "err", err)
		os.Exit(1)
	}

	database, err := db.Open(cfg.DatabasePath)
	if err != nil {
		slog.Error("error de base de datos", "err", err)
		os.Exit(1)
	}
	defer database.Close()

	if err := runMigrations(database); err != nil {
		slog.Error("error de migración", "err", err)
		os.Exit(1)
	}
	slog.Info("base de datos lista", "path", cfg.DatabasePath)

	authRepo := auth.NewRepository(database)
	authService := auth.NewService(authRepo)

	noteRepo := note.NewRepository(database)
	noteService := note.NewService(noteRepo)

	todoRepo := todo.NewRepository(database)
	todoService := todo.NewService(todoRepo)

	authHandler := handler.NewAuthHandler(authService)
	noteHandler := handler.NewNoteHandler(noteService)
	todoHandler := handler.NewTodoHandler(todoService)
	searchHandler := handler.NewSearchHandler(noteService, todoService)

	r := chi.NewRouter()

	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)
	r.Use(middleware.Compress(5))
	r.Use(middleware.Timeout(15 * time.Second))
	r.Use(securityHeaders)

	r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))

	// Rutas públicas
	r.Get("/login", authHandler.ShowLogin)
	r.Post("/login", authHandler.Login)
	r.Get("/register", authHandler.ShowRegister)
	r.Post("/register", authHandler.Register)

	// Rutas protegidas
	r.Group(func(r chi.Router) {
		r.Use(authService.Middleware)

		r.Post("/logout", authHandler.Logout)

		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			http.Redirect(w, r, "/notes", http.StatusSeeOther)
		})

		// Notas
		r.Get("/notes", noteHandler.List)
		r.Get("/notes/new", noteHandler.ShowNew)
		r.Post("/notes", noteHandler.Create)
		r.Get("/notes/{noteID}", noteHandler.Show)
		r.Get("/notes/{noteID}/edit", noteHandler.ShowEdit)
		r.Post("/notes/{noteID}", noteHandler.Update)
		r.Delete("/notes/{noteID}", noteHandler.Delete)
		r.Post("/notes/{noteID}/pin", noteHandler.TogglePin)
		r.Post("/notes/preview", noteHandler.Preview)

		// Tareas
		r.Get("/todos", todoHandler.List)
		r.Get("/todos/new", todoHandler.ShowNew)
		r.Post("/todos", todoHandler.Create)
		r.Get("/todos/{listID}", todoHandler.ShowDetail)
		r.Post("/todos/{listID}/items", todoHandler.AddItem)
		r.Post("/todos/items/{itemID}/toggle", todoHandler.ToggleItem)
		r.Delete("/todos/items/{itemID}", todoHandler.DeleteItem)

		// Búsqueda
		r.Get("/search", searchHandler.Search)
	})

	srv := &http.Server{
		Addr:         cfg.Addr(),
		Handler:      r,
		ReadTimeout:  cfg.ReadTimeout,
		WriteTimeout: cfg.WriteTimeout,
	}

	done := make(chan os.Signal, 1)
	signal.Notify(done, os.Interrupt, syscall.SIGTERM)

	go func() {
		slog.Info("servidor iniciando", "addr", cfg.Addr())
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("error del servidor", "err", err)
			os.Exit(1)
		}
	}()

	<-done
	slog.Info("apagando servidor")
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	srv.Shutdown(ctx)
	slog.Info("detenido")
}

func runMigrations(database *sql.DB) error {
	driver, err := sqlite.WithInstance(database, &sqlite.Config{})
	if err != nil {
		return err
	}
	m, err := migrate.NewWithDatabaseInstance("file://migrations", "sqlite", driver)
	if err != nil {
		return err
	}
	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
		return err
	}
	return nil
}

func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("X-XSS-Protection", "1; mode=block")
		w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
		next.ServeHTTP(w, r)
	})
}

Pruebas

// internal/domain/todo_test.go
package domain

import (
	"testing"
	"time"
)

func TestTodoList_Progress(t *testing.T) {
	tests := []struct {
		name     string
		items    []TodoItem
		expected int
	}{
		{
			name:     "lista vacía devuelve 0",
			items:    []TodoItem{},
			expected: 0,
		},
		{
			name:     "todas completadas devuelve 100",
			items:    []TodoItem{{Done: true}, {Done: true}},
			expected: 100,
		},
		{
			name:     "la mitad completada devuelve 50",
			items:    []TodoItem{{Done: true}, {Done: false}},
			expected: 50,
		},
		{
			name:     "una de tres completada devuelve 33",
			items:    []TodoItem{{Done: true}, {Done: false}, {Done: false}},
			expected: 33,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			list := &TodoList{Items: tt.items}
			if got := list.Progress(); got != tt.expected {
				t.Errorf("Progress() = %d, quería %d", got, tt.expected)
			}
		})
	}
}

func TestTodoList_IsOverdue(t *testing.T) {
	pasado := time.Now().Add(-24 * time.Hour)
	futuro := time.Now().Add(24 * time.Hour)

	tests := []struct {
		name     string
		dueDate  *time.Time
		done     bool
		expected bool
	}{
		{"sin fecha límite nunca está vencida", nil, false, false},
		{"fecha pasada con pendientes está vencida", &pasado, false, true},
		{"fecha pasada pero 100% completa no está vencida", &pasado, true, false},
		{"fecha futura no está vencida", &futuro, false, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var items []TodoItem
			if tt.done {
				items = []TodoItem{{Done: true}}
			} else {
				items = []TodoItem{{Done: false}}
			}
			list := &TodoList{DueDate: tt.dueDate, Items: items}

			if got := list.IsOverdue(); got != tt.expected {
				t.Errorf("IsOverdue() = %v, quería %v", got, tt.expected)
			}
		})
	}
}
// internal/note/service_test.go
package note

import (
	"testing"
)

func TestCountWords(t *testing.T) {
	tests := []struct {
		input    string
		expected int
	}{
		{"", 0},
		{"hola mundo", 2},
		{"  muchos   espacios   ", 2},
		{"uno", 1},
	}

	for _, tt := range tests {
		t.Run(tt.input, func(t *testing.T) {
			if got := countWords(tt.input); got != tt.expected {
				t.Errorf("countWords(%q) = %d, quería %d", tt.input, got, tt.expected)
			}
		})
	}
}

func TestNormalizeTagNames(t *testing.T) {
	names := []string{"Go", "  golang ", "GO", "backend"}
	tags := normalizeTagNames(names)

	// "go" y "golang" son diferentes, "GO" duplica "go"
	// Resultado esperado: "go", "golang", "backend" (3 únicos)
	if len(tags) != 3 {
		t.Errorf("esperaba 3 tags únicos, obtuve %d", len(tags))
	}
}

Dockerfile

FROM golang:1.25-alpine AS builder

WORKDIR /app
RUN go install github.com/a-h/templ/cmd/templ@latest

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN templ generate
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /noteflow ./cmd/server

FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata

WORKDIR /app
COPY --from=builder /noteflow .
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/static ./static

VOLUME ["/app/data"]
ENV DATABASE_PATH=/app/data/noteflow.db

EXPOSE 8080
ENTRYPOINT ["./noteflow"]

El volumen de base de datos en /app/data persiste el archivo SQLite entre reinicios del contenedor. Esta es toda la capa de persistencia — un archivo, un montaje de volumen.


go.mod

module noteflow

go 1.25

require (
	github.com/a-h/templ v0.3.906
	github.com/caarlos0/env/v11 v11.3.1
	github.com/go-chi/chi/v5 v5.2.1
	github.com/golang-migrate/migrate/v4 v4.18.3
	github.com/google/uuid v1.6.0
	github.com/yuin/goldmark v1.7.12
	golang.org/x/crypto v0.38.0
	modernc.org/sqlite v1.37.1
)

Makefile

.PHONY: dev build test templ tailwind migrate-up migrate-down clean setup

dev:
	air

build: templ tailwind
	go build -ldflags="-s -w" -o bin/noteflow ./cmd/server

test:
	go test ./... -v -race -count=1

templ:
	templ generate

tailwind:
	npx tailwindcss -i static/css/input.css -o static/css/output.css --minify

tailwind-watch:
	npx tailwindcss -i static/css/input.css -o static/css/output.css --watch

migrate-up:
	go run ./cmd/migrate up

migrate-down:
	go run ./cmd/migrate down 1

clean:
	rm -f bin/noteflow noteflow.db
	rm -f static/css/output.css
	find internal/view -name "*_templ.go" -delete

setup:
	go install github.com/a-h/templ/cmd/templ@latest
	go install github.com/air-verse/air@latest
	npm install
	cp .env.example .env

La Experiencia de Uso

El panel de vista previa de markdown en el editor de notas se actualiza mientras escribes. No hay debounce visible — HTMX agrupa las pulsaciones (retraso de 500ms) y envía una petición. El servidor renderiza el markdown y devuelve el fragmento HTML. El panel de vista previa se intercambia. Para el usuario, se siente como una vista previa en vivo construida con una librería de editor JavaScript compleja. Para el desarrollador, son quince líneas de atributos HTMX y un endpoint Go.

Esta es la ventaja de productividad del full stack Go: las funciones que parecen complejas de construir son a menudo simples cuando el servidor posee el renderizado. La vista previa de markdown, la búsqueda instantánea, el toggle del checkbox, el badge de nota fijada — cada interacción es una petición HTTP, una consulta a la base de datos y un render Templ. Sin sincronización de estado, sin serialización, sin modelo de datos en el cliente que mantener sincronizado con el servidor.

Una base de código que cabe en la cabeza de una persona se envía más rápido que una que requiere un equipo para traducir entre capas. El full stack Go no es un compromiso — es una elección para optimizar lo que realmente importa: construir algo útil y mantenerlo funcionando.