Full Stack con Go: Planificador de Tareas con Calendario, Proyectos y Recurrencia

Full Stack con Go: Planificador de Tareas con Calendario, Proyectos y Recurrencia

Construye Planify en Go: planificador completo con vista de calendario, proyectos, etiquetas, tareas recurrentes, dashboard de hoy, HTMX, Templ, Tailwind y PostgreSQL.

Por Omar Flores

Full Stack con Go: Planificador de Tareas con Calendario, Proyectos y Recurrencia

Un calendario es una máquina para hacer visible el tiempo. Todo planificador de tareas que funciona resuelve el mismo problema fundamental: ayudar a una persona a ver qué ha comprometido, cuándo, y si esos compromisos son realistas.

Esta guía construye Planify — un planificador de tareas con dashboard de hoy, vista de calendario mensual, organización por proyectos, etiquetas, prioridades, tareas recurrentes y actualizaciones en tiempo real con HTMX. El stack completo es Go: PostgreSQL para la persistencia (las tareas recurrentes requieren aritmética real de fechas), Templ para HTML renderizado en servidor type-safe, HTMX para mutaciones sin recargas, Alpine.js para estado local de UI, y Tailwind CSS para la interfaz.

Este proyecto introduce patrones que los posts anteriores de full-stack Go no cubrieron: renderizado de grilla de calendario en Templ, lógica de expansión de tareas recurrentes, consultas SQL con conciencia de fechas, y navegación multi-vista (dashboard, calendario, tablero de proyectos).


El Stack

CapaTecnologíaPor qué
RouterChi v5Middleware, parámetros URL, grupos de rutas
TemplatesTemplType-safe, compilado, soporte IDE
ReactividadHTMXIntercambios dirigidos por servidor, sin overhead SPA
Estado clienteAlpine.jsNavegación de calendario, date pickers, dropdowns
EstilosTailwind CSSUtility-first, responsivo, modo oscuro
Base de datosPostgreSQL 17 + pgx v5Aritmética de fechas, consultas de tareas recurrentes
Authbcrypt + cookies HTTP-onlyBasada en sesiones, sin complejidad JWT
Migracionesgolang-migrateCambios de esquema SQL versionados

Estructura del Proyecto

planify/
  cmd/
    server/
      main.go
  internal/
    config/
      config.go
    domain/
      task.go           # Task, RecurrenceRule, Priority, Status
      project.go        # Entidad Project
      label.go          # Entidad Label
      user.go           # User, Session
      errors.go
      calendar.go       # Value objects CalendarDay, CalendarMonth
    auth/
      repository.go
      service.go
      middleware.go
    task/
      repository.go     # CRUD, consultas por fecha, expansión recurrente
      service.go        # Lógica de recurrencia, hoy/próximo/vencido
    project/
      repository.go
      service.go
    handler/
      auth.go
      task.go           # CRUD de tareas + toggle de completado
      calendar.go       # Handler de vista de calendario
      dashboard.go      # Hoy + próximo + vencido
      project.go
    view/
      layout.templ      # Layout base con nav lateral
      auth.templ
      dashboard/
        index.templ     # Secciones de hoy, próximo, vencido
      calendar/
        month.templ     # Grilla mensual
        day.templ       # Panel de detalle del día (parcial HTMX)
      task/
        form.templ      # Crear / editar con opciones de recurrencia
        card.templ      # Componente tarjeta de tarea
        list.templ      # Vista de lista plana
      project/
        list.templ
        detail.templ    # Proyecto con tablero de tareas
      components/
        priority.templ
        label.templ
        empty.templ
  migrations/
    001_users.up.sql
    002_projects.up.sql
    003_tasks.up.sql
    004_labels.up.sql
  static/
    css/output.css
    js/htmx.min.js
    js/alpine.min.js
  docker-compose.yml
  Makefile
  .air.toml
  go.mod

Capa de Dominio

El dominio contiene los conceptos centrales: una tarea tiene fecha límite, prioridad, estado, proyecto opcional, cero o más etiquetas, y una regla de recurrencia opcional. La regla de recurrencia se almacena como datos estructurados — no como una cadena que parseas en tiempo de ejecución.

// internal/domain/task.go
package domain

import (
	"time"

	"github.com/google/uuid"
)

type Task struct {
	ID          uuid.UUID
	UserID      uuid.UUID
	ProjectID   *uuid.UUID
	Title       string
	Description string
	Priority    Priority
	Status      Status
	DueDate     *time.Time
	DueTime     *time.Time       // hora del día opcional
	Labels      []Label
	Recurrence  *RecurrenceRule
	ParentID    *uuid.UUID       // no-nil para instancias recurrentes
	CompletedAt *time.Time
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

type Priority int

const (
	PriorityNone   Priority = 0
	PriorityLow    Priority = 1
	PriorityMedium Priority = 2
	PriorityHigh   Priority = 3
	PriorityUrgent Priority = 4
)

func (p Priority) Label() string {
	switch p {
	case PriorityLow:
		return "Baja"
	case PriorityMedium:
		return "Media"
	case PriorityHigh:
		return "Alta"
	case PriorityUrgent:
		return "Urgente"
	default:
		return "Sin prioridad"
	}
}

func (p Priority) Color() string {
	switch p {
	case PriorityLow:
		return "text-sky-600 dark:text-sky-400"
	case PriorityMedium:
		return "text-amber-600 dark:text-amber-400"
	case PriorityHigh:
		return "text-orange-600 dark:text-orange-400"
	case PriorityUrgent:
		return "text-red-600 dark:text-red-400"
	default:
		return "text-gray-400"
	}
}

func (p Priority) DotColor() string {
	switch p {
	case PriorityLow:
		return "bg-sky-400"
	case PriorityMedium:
		return "bg-amber-400"
	case PriorityHigh:
		return "bg-orange-500"
	case PriorityUrgent:
		return "bg-red-500"
	default:
		return "bg-gray-300 dark:bg-gray-600"
	}
}

type Status int

const (
	StatusTodo       Status = 0
	StatusInProgress Status = 1
	StatusDone       Status = 2
)

func (s Status) IsDone() bool { return s == StatusDone }

func (t *Task) IsOverdue() bool {
	if t.DueDate == nil || t.Status.IsDone() {
		return false
	}
	today := time.Now().Truncate(24 * time.Hour)
	due := t.DueDate.Truncate(24 * time.Hour)
	return due.Before(today)
}

func (t *Task) IsDueToday() bool {
	if t.DueDate == nil {
		return false
	}
	today := time.Now().Truncate(24 * time.Hour)
	due := t.DueDate.Truncate(24 * time.Hour)
	return due.Equal(today)
}

func (t *Task) IsDueSoon() bool {
	if t.DueDate == nil {
		return false
	}
	now := time.Now()
	in7Days := now.Add(7 * 24 * time.Hour)
	return t.DueDate.Before(in7Days) && !t.IsOverdue() && !t.IsDueToday()
}
// internal/domain/task.go (continuación — RecurrenceRule)

type RecurrenceFrequency string

const (
	FreqDaily   RecurrenceFrequency = "daily"
	FreqWeekly  RecurrenceFrequency = "weekly"
	FreqMonthly RecurrenceFrequency = "monthly"
	FreqYearly  RecurrenceFrequency = "yearly"
)

type RecurrenceRule struct {
	Frequency  RecurrenceFrequency
	Interval   int        // cada N (días/semanas/meses/años)
	DaysOfWeek []int      // 0=Dom, 1=Lun ... para recurrencia semanal
	EndDate    *time.Time
	MaxCount   *int
}

// NextOccurrence devuelve la próxima fecha de vencimiento después de `from`.
func (r *RecurrenceRule) NextOccurrence(from time.Time) *time.Time {
	if r == nil {
		return nil
	}
	n := r.interval(from)
	if r.EndDate != nil && n.After(*r.EndDate) {
		return nil
	}
	return &n
}

func (r *RecurrenceRule) interval(from time.Time) time.Time {
	switch r.Frequency {
	case FreqDaily:
		return from.AddDate(0, 0, r.Interval)
	case FreqWeekly:
		return from.AddDate(0, 0, 7*r.Interval)
	case FreqMonthly:
		return from.AddDate(0, r.Interval, 0)
	case FreqYearly:
		return from.AddDate(r.Interval, 0, 0)
	default:
		return from.AddDate(0, 0, r.Interval)
	}
}
// internal/domain/calendar.go
package domain

import "time"

// CalendarDay representa una celda de día individual en la grilla del calendario mensual.
type CalendarDay struct {
	Date           time.Time
	Tasks          []Task
	IsToday        bool
	IsCurrentMonth bool
	IsWeekend      bool
}

// CalendarMonth es la grilla completa para la vista de calendario mensual.
type CalendarMonth struct {
	Year  int
	Month time.Month
	Days  []CalendarDay // siempre 35 o 42 celdas (5 o 6 filas de 7)
}

func (m *CalendarMonth) Title() string {
	months := map[time.Month]string{
		time.January: "Enero", time.February: "Febrero", time.March: "Marzo",
		time.April: "Abril", time.May: "Mayo", time.June: "Junio",
		time.July: "Julio", time.August: "Agosto", time.September: "Septiembre",
		time.October: "Octubre", time.November: "Noviembre", time.December: "Diciembre",
	}
	return fmt.Sprintf("%s %d", months[m.Month], m.Year)
}

func (m *CalendarMonth) PrevMonth() (int, time.Month) {
	t := time.Date(m.Year, m.Month, 1, 0, 0, 0, 0, time.UTC).AddDate(0, -1, 0)
	return t.Year(), t.Month()
}

func (m *CalendarMonth) NextMonth() (int, time.Month) {
	t := time.Date(m.Year, m.Month, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0)
	return t.Year(), t.Month()
}
// internal/domain/project.go
package domain

import (
	"time"
	"github.com/google/uuid"
)

type Project struct {
	ID        uuid.UUID
	UserID    uuid.UUID
	Name      string
	Color     string   // nombre de color Tailwind: "blue", "green", etc.
	TaskCount int      // total de tareas (se llena al obtener)
	DoneCount int      // tareas completadas
	CreatedAt time.Time
}

func (p *Project) Progress() int {
	if p.TaskCount == 0 {
		return 0
	}
	return (p.DoneCount * 100) / p.TaskCount
}

func (p *Project) ColorDot() string {
	return "bg-" + p.Color + "-500"
}

func (p *Project) ColorText() string {
	return "text-" + p.Color + "-600 dark:text-" + p.Color + "-400"
}

Esquema de Base de Datos

Cuatro archivos de migración. Las tareas referencian proyectos y soportan relaciones padre-hijo para instancias recurrentes.

-- migrations/001_users.up.sql
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    display_name TEXT NOT NULL,
    timezone TEXT NOT NULL DEFAULT 'UTC',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

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

CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- migrations/002_projects.up.sql
CREATE TABLE projects (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    color TEXT NOT NULL DEFAULT 'blue',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_projects_user ON projects(user_id);
-- migrations/003_tasks.up.sql
CREATE TABLE tasks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
    parent_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    priority INTEGER NOT NULL DEFAULT 0,
    status INTEGER NOT NULL DEFAULT 0,
    due_date DATE,
    due_time TIME,
    -- Recurrencia almacenada como JSONB para flexibilidad
    recurrence JSONB,
    completed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_tasks_user ON tasks(user_id);
CREATE INDEX idx_tasks_project ON tasks(project_id);
-- Índice parcial: solo tareas incompletas tienen fecha de vencimiento relevante
CREATE INDEX idx_tasks_due_date ON tasks(user_id, due_date) WHERE status != 2;
CREATE INDEX idx_tasks_parent ON tasks(parent_id);
CREATE INDEX idx_tasks_status ON tasks(user_id, status);
-- migrations/004_labels.up.sql
CREATE TABLE labels (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    color TEXT NOT NULL DEFAULT 'gray',
    UNIQUE(user_id, name)
);

CREATE TABLE task_labels (
    task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
    PRIMARY KEY (task_id, label_id)
);

CREATE INDEX idx_task_labels_task ON task_labels(task_id);
CREATE INDEX idx_task_labels_label ON task_labels(label_id);

La columna recurrence JSONB almacena el RecurrenceRule serializado como JSON. Esto evita agregar columnas específicas de recurrencia a la tabla de tareas y permite evolucionar el formato de recurrencia sin una migración de esquema.

El índice parcial WHERE status != 2 en due_date significa que el índice de fechas solo cubre tareas incompletas. Las tareas completadas nunca se consultan por fecha de vencimiento en el camino crítico.


Repositorio de Tareas

// internal/task/repository.go
package task

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"planify/internal/domain"
)

type Repository struct {
	pool *pgxpool.Pool
}

func NewRepository(pool *pgxpool.Pool) *Repository {
	return &Repository{pool: pool}
}

// GetToday devuelve todas las tareas con vencimiento hoy para el usuario.
func (r *Repository) GetToday(ctx context.Context, userID uuid.UUID) ([]domain.Task, error) {
	today := time.Now().Format("2006-01-02")
	return r.queryTasks(ctx, userID,
		`WHERE t.user_id = $1 AND t.due_date = $2 AND t.status != 2
		 ORDER BY t.priority DESC, t.due_time ASC NULLS LAST`,
		userID, today,
	)
}

// GetOverdue devuelve todas las tareas incompletas con fecha anterior a hoy.
func (r *Repository) GetOverdue(ctx context.Context, userID uuid.UUID) ([]domain.Task, error) {
	today := time.Now().Format("2006-01-02")
	return r.queryTasks(ctx, userID,
		`WHERE t.user_id = $1 AND t.due_date < $2 AND t.status != 2
		 ORDER BY t.due_date ASC, t.priority DESC`,
		userID, today,
	)
}

// GetUpcoming devuelve tareas con vencimiento en los próximos N días (excluyendo hoy).
func (r *Repository) GetUpcoming(ctx context.Context, userID uuid.UUID, days int) ([]domain.Task, error) {
	today := time.Now().Format("2006-01-02")
	future := time.Now().AddDate(0, 0, days).Format("2006-01-02")
	return r.queryTasks(ctx, userID,
		`WHERE t.user_id = $1 AND t.due_date > $2 AND t.due_date <= $3 AND t.status != 2
		 ORDER BY t.due_date ASC, t.priority DESC`,
		userID, today, future,
	)
}

// GetForDateRange devuelve todas las tareas en un rango de fechas (para vista de calendario).
func (r *Repository) GetForDateRange(ctx context.Context, userID uuid.UUID, from, to time.Time) ([]domain.Task, error) {
	fromStr := from.Format("2006-01-02")
	toStr := to.Format("2006-01-02")
	return r.queryTasks(ctx, userID,
		`WHERE t.user_id = $1 AND t.due_date >= $2 AND t.due_date <= $3
		 ORDER BY t.due_date ASC, t.priority DESC`,
		userID, fromStr, toStr,
	)
}

// Create inserta una nueva tarea.
func (r *Repository) Create(ctx context.Context, task *domain.Task) error {
	var recJSON []byte
	if task.Recurrence != nil {
		var err error
		recJSON, err = json.Marshal(task.Recurrence)
		if err != nil {
			return fmt.Errorf("serializar recurrencia: %w", err)
		}
	}

	var dueDate, dueTime, projectID any
	if task.DueDate != nil {
		dueDate = task.DueDate.Format("2006-01-02")
	}
	if task.DueTime != nil {
		dueTime = task.DueTime.Format("15:04")
	}
	if task.ProjectID != nil {
		projectID = task.ProjectID
	}

	_, err := r.pool.Exec(ctx,
		`INSERT INTO tasks (id, user_id, project_id, parent_id, title, description,
		                    priority, status, due_date, due_time, recurrence, created_at, updated_at)
		 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
		task.ID, task.UserID, projectID, task.ParentID,
		task.Title, task.Description, task.Priority, task.Status,
		dueDate, dueTime, recJSON, task.CreatedAt, task.UpdatedAt,
	)
	return err
}

// Complete marca una tarea como completada.
func (r *Repository) Complete(ctx context.Context, taskID, userID uuid.UUID) error {
	_, err := r.pool.Exec(ctx,
		`UPDATE tasks SET status = 2, completed_at = now(), updated_at = now()
		 WHERE id = $1 AND user_id = $2`,
		taskID, userID,
	)
	return err
}

// Uncomplete revierte una tarea a pendiente.
func (r *Repository) Uncomplete(ctx context.Context, taskID, userID uuid.UUID) error {
	_, err := r.pool.Exec(ctx,
		`UPDATE tasks SET status = 0, completed_at = NULL, updated_at = now()
		 WHERE id = $1 AND user_id = $2`,
		taskID, userID,
	)
	return err
}

// Delete elimina una tarea y todos sus hijos recurrentes.
func (r *Repository) Delete(ctx context.Context, taskID, userID uuid.UUID) error {
	_, err := r.pool.Exec(ctx,
		`DELETE FROM tasks WHERE (id = $1 OR parent_id = $1) AND user_id = $2`,
		taskID, userID,
	)
	return err
}

// queryTasks es el constructor de consultas único para todas las obtenciones de tareas.
func (r *Repository) queryTasks(ctx context.Context, userID uuid.UUID, where string, args ...any) ([]domain.Task, error) {
	query := fmt.Sprintf(`
		SELECT
			t.id, t.user_id, t.project_id, t.parent_id,
			t.title, t.description, t.priority, t.status,
			t.due_date, t.due_time, t.recurrence,
			t.completed_at, t.created_at, t.updated_at
		FROM tasks t
		%s`, where)

	rows, err := r.pool.Query(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("consultar tareas: %w", err)
	}
	defer rows.Close()

	var tasks []domain.Task
	taskIDs := make([]string, 0)

	for rows.Next() {
		var task domain.Task
		var projectID, parentID pgx.NullText
		var dueDate, dueTime sql.NullString
		var recJSON []byte
		var completedAt sql.NullTime

		err := rows.Scan(
			&task.ID, &task.UserID, &projectID, &parentID,
			&task.Title, &task.Description, &task.Priority, &task.Status,
			&dueDate, &dueTime, &recJSON,
			&completedAt, &task.CreatedAt, &task.UpdatedAt,
		)
		if err != nil {
			return nil, fmt.Errorf("escanear tarea: %w", err)
		}

		if projectID.Valid {
			id, _ := uuid.Parse(projectID.String)
			task.ProjectID = &id
		}
		if parentID.Valid {
			id, _ := uuid.Parse(parentID.String)
			task.ParentID = &id
		}
		if dueDate.Valid {
			t, _ := time.Parse("2006-01-02", dueDate.String)
			task.DueDate = &t
		}
		if dueTime.Valid {
			t, _ := time.Parse("15:04", dueTime.String)
			task.DueTime = &t
		}
		if len(recJSON) > 0 {
			var rec domain.RecurrenceRule
			if err := json.Unmarshal(recJSON, &rec); err == nil {
				task.Recurrence = &rec
			}
		}
		if completedAt.Valid {
			task.CompletedAt = &completedAt.Time
		}

		tasks = append(tasks, task)
		taskIDs = append(taskIDs, "'"+task.ID.String()+"'")
	}

	if len(tasks) == 0 {
		return tasks, nil
	}

	if err := r.loadLabels(ctx, tasks, taskIDs); err != nil {
		return nil, err
	}

	return tasks, nil
}

func (r *Repository) loadLabels(ctx context.Context, tasks []domain.Task, ids []string) error {
	query := fmt.Sprintf(`
		SELECT tl.task_id, l.id, l.name, l.color
		FROM task_labels tl JOIN labels l ON tl.label_id = l.id
		WHERE tl.task_id IN (%s)`, joinStrings(ids, ","))

	rows, err := r.pool.Query(ctx, query)
	if err != nil {
		return err
	}
	defer rows.Close()

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

	for i := range tasks {
		tasks[i].Labels = labelMap[tasks[i].ID.String()]
	}
	return nil
}

func joinStrings(ss []string, sep string) string {
	result := ""
	for i, s := range ss {
		if i > 0 {
			result += sep
		}
		result += s
	}
	return result
}

Servicio de Tareas: Lógica de Recurrencia

Cuando se completa una tarea recurrente, el servicio crea la siguiente instancia basándose en la regla de recurrencia. El usuario ve automáticamente una nueva tarea aparecer en la próxima fecha de vencimiento.

// internal/task/service.go
package task

import (
	"context"
	"time"

	"github.com/google/uuid"

	"planify/internal/domain"
)

type Service struct {
	repo *Repository
}

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

func (s *Service) GetDashboard(ctx context.Context, userID uuid.UUID) (today, overdue, upcoming []domain.Task, err error) {
	today, err = s.repo.GetToday(ctx, userID)
	if err != nil {
		return
	}
	overdue, err = s.repo.GetOverdue(ctx, userID)
	if err != nil {
		return
	}
	upcoming, err = s.repo.GetUpcoming(ctx, userID, 7)
	return
}

func (s *Service) GetCalendarMonth(ctx context.Context, userID uuid.UUID, year int, month time.Month) (*domain.CalendarMonth, error) {
	// Calcular el rango de fechas para la grilla del calendario
	firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)

	// Inicio de la grilla: el domingo en o antes del primero del mes
	gridStart := firstOfMonth
	for gridStart.Weekday() != time.Sunday {
		gridStart = gridStart.AddDate(0, 0, -1)
	}

	// Fin de la grilla: siempre mostrar 6 semanas completas (42 celdas)
	gridEnd := gridStart.AddDate(0, 0, 41)

	tasks, err := s.repo.GetForDateRange(ctx, userID, gridStart, gridEnd)
	if err != nil {
		return nil, err
	}

	// Indexar tareas por cadena de fecha para búsqueda O(1)
	tasksByDate := make(map[string][]domain.Task)
	for _, task := range tasks {
		if task.DueDate != nil {
			key := task.DueDate.Format("2006-01-02")
			tasksByDate[key] = append(tasksByDate[key], task)
		}
	}

	today := time.Now().Truncate(24 * time.Hour)
	cal := &domain.CalendarMonth{Year: year, Month: month}

	for d := gridStart; !d.After(gridEnd); d = d.AddDate(0, 0, 1) {
		key := d.Format("2006-01-02")
		day := domain.CalendarDay{
			Date:           d,
			Tasks:          tasksByDate[key],
			IsToday:        d.Equal(today),
			IsCurrentMonth: d.Month() == month,
			IsWeekend:      d.Weekday() == time.Saturday || d.Weekday() == time.Sunday,
		}
		cal.Days = append(cal.Days, day)
	}

	return cal, nil
}

func (s *Service) CreateTask(ctx context.Context, task *domain.Task) error {
	task.ID = uuid.New()
	task.CreatedAt = time.Now()
	task.UpdatedAt = time.Now()
	return s.repo.Create(ctx, task)
}

// CompleteTask marca una tarea como hecha. Si es recurrente, programa la siguiente instancia.
func (s *Service) CompleteTask(ctx context.Context, taskID, userID uuid.UUID) error {
	task, err := s.repo.GetByID(ctx, taskID, userID)
	if err != nil {
		return err
	}

	if err := s.repo.Complete(ctx, taskID, userID); err != nil {
		return err
	}

	// Si la tarea tiene regla de recurrencia y fecha de vencimiento, crear la siguiente instancia
	if task.Recurrence != nil && task.DueDate != nil {
		next := task.Recurrence.NextOccurrence(*task.DueDate)
		if next != nil {
			nextTask := &domain.Task{
				UserID:      task.UserID,
				ProjectID:   task.ProjectID,
				ParentID:    &task.ID,
				Title:       task.Title,
				Description: task.Description,
				Priority:    task.Priority,
				Status:      domain.StatusTodo,
				DueDate:     next,
				DueTime:     task.DueTime,
				Recurrence:  task.Recurrence,
			}
			return s.CreateTask(ctx, nextTask)
		}
	}

	return nil
}

El método CompleteTask es el corazón de la lógica de recurrencia. Cuando se completa una tarea recurrente, llama NextOccurrence en la regla, y si existe una próxima fecha válida, crea una nueva tarea con ParentID apuntando a la tarea completada. La cadena de instancias se preserva en la base de datos via la foreign key parent_id.


Templates

Layout Base

// internal/view/layout.templ
package view

import (
	"fmt"
	"planify/internal/domain"
)

templ Layout(title string, user *domain.User, projects []domain.Project) {
	<!DOCTYPE html>
	<html lang="es" class="h-full"
	      x-data="{ dark: localStorage.getItem('dark') === '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 } — Planify</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="w-60 flex-shrink-0 h-screen bg-white dark:bg-gray-900
		              border-r border-gray-200 dark:border-gray-800 flex flex-col">
			<div class="h-14 flex items-center px-5 border-b border-gray-100 dark:border-gray-800">
				<span class="font-bold text-blue-600 dark:text-blue-400 text-xl">Planify</span>
			</div>

			<nav class="px-3 py-4 space-y-0.5">
				@NavItem("/", "Hoy", "today", title == "Hoy")
				@NavItem("/upcoming", "Próximas", "upcoming", title == "Próximas")
				@NavItem("/calendar", "Calendario", "calendar", title == "Calendario")
			</nav>

			<div class="px-3 py-2 flex-1 overflow-y-auto">
				<div class="flex items-center justify-between mb-2 px-2">
					<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
						Proyectos
					</span>
					<a href="/projects/new"
					   class="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 text-lg leading-none">
						+
					</a>
				</div>
				for _, p := range projects {
					<a href={ templ.SafeURL("/projects/" + p.ID.String()) }
					   class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm
					          text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800
					          transition-colors group">
						<span class={ "w-2 h-2 rounded-full flex-shrink-0 " + p.ColorDot() }></span>
						<span class="flex-1 truncate">{ p.Name }</span>
						if p.TaskCount > 0 {
							<span class="text-xs text-gray-400 opacity-0 group-hover:opacity-100">
								{ fmt.Sprintf("%d", p.TaskCount-p.DoneCount) }
							</span>
						}
					</a>
				}
			</div>

			<div class="h-14 border-t border-gray-100 dark:border-gray-800 px-4 flex items-center gap-3">
				<div class="w-7 h-7 rounded-full bg-blue-600 flex items-center justify-center
				            text-white text-xs font-bold flex-shrink-0">
					{ initial(user.DisplayName) }
				</div>
				<span class="text-sm truncate flex-1 text-gray-700 dark:text-gray-300">
					{ user.DisplayName }
				</span>
				<button @click="dark = !dark"
				        class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-400 text-sm">
					<span x-show="!dark">O</span>
					<span x-show="dark">C</span>
				</button>
				<form hx-post="/logout">
					<button type="submit" class="text-xs text-gray-400 hover:text-red-500">Salir</button>
				</form>
			</div>
		</aside>

		<!-- Contenido principal -->
		<div class="flex-1 flex flex-col h-screen overflow-hidden min-w-0">
			<main class="flex-1 overflow-y-auto">
				{ children... }
			</main>
		</div>
	</body>
	</html>
}

templ NavItem(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 " + navClass(active) }>
		<span class="w-4 h-4 flex-shrink-0">{ navIcon(icon) }</span>
		{ label }
	</a>
}

func navClass(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 navIcon(icon string) string {
	switch icon {
	case "today": return "H"
	case "upcoming": return "P"
	case "calendar": return "C"
	default: return "·"
	}
}

func initial(name string) string {
	if name == "" { return "?" }
	return string([]rune(name)[0:1])
}

Dashboard de Hoy

// internal/view/dashboard/index.templ
package dashboard

import (
	"fmt"
	"time"
	"planify/internal/domain"
	"planify/internal/view"
)

templ IndexPage(user *domain.User, projects []domain.Project, today, overdue, upcoming []domain.Task) {
	@view.Layout("Hoy", user, projects) {
		<div class="max-w-2xl mx-auto px-6 py-8">
			<div class="mb-8">
				<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Hoy</h1>
				<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
					{ todayDate() }
				</p>
			</div>

			@QuickAddForm(nil)

			<!-- Vencidas -->
			if len(overdue) > 0 {
				<section class="mb-8" id="overdue-section">
					<h2 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-3 flex items-center gap-2">
						<span class="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
						Vencidas ({ fmt.Sprintf("%d", len(overdue)) })
					</h2>
					<div class="space-y-1" id="overdue-list">
						for _, task := range overdue {
							@TaskRow(task)
						}
					</div>
				</section>
			}

			<!-- Tareas de hoy -->
			<section class="mb-8" id="today-section">
				<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 flex items-center gap-2">
					<span class="w-1.5 h-1.5 bg-blue-500 rounded-full"></span>
					Hoy ({ fmt.Sprintf("%d", len(today)) })
				</h2>
				<div class="space-y-1" id="today-list">
					if len(today) == 0 {
						<p class="text-sm text-gray-400 dark:text-gray-500 py-4 text-center">
							Todo despejado para hoy.
						</p>
					}
					for _, task := range today {
						@TaskRow(task)
					}
				</div>
			</section>

			<!-- Próximas -->
			if len(upcoming) > 0 {
				<section id="upcoming-section">
					<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 flex items-center gap-2">
						<span class="w-1.5 h-1.5 bg-gray-300 dark:bg-gray-600 rounded-full"></span>
						Próximos 7 días
					</h2>
					<div class="space-y-1">
						for _, task := range upcoming {
							@TaskRow(task)
						}
					</div>
				</section>
			}
		</div>
	}
}

templ TaskRow(task domain.Task) {
	<div class={ "group flex items-start gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-100 " +
	            "dark:hover:bg-gray-800/50 transition-colors " + taskRowClass(task) }
	     id={ "task-" + task.ID.String() }>
		<!-- Checkbox de completado -->
		<button hx-post={ completionURL(task) }
		        hx-target={ "#task-" + task.ID.String() }
		        hx-swap="outerHTML"
		        class={ "w-4 h-4 mt-0.5 rounded-full border-2 flex-shrink-0 transition-all " +
		               checkboxStyle(task) }>
		</button>

		<div class="flex-1 min-w-0">
			<div class="flex items-center gap-2">
				if task.Priority != domain.PriorityNone {
					<span class={ "w-1.5 h-1.5 rounded-full flex-shrink-0 " + task.Priority.DotColor() }></span>
				}
				<span class={ "text-sm " + taskTitleClass(task) }>{ task.Title }</span>
			</div>

			<div class="flex items-center gap-3 mt-0.5">
				if task.DueDate != nil {
					<span class={ "text-xs " + dueDateClass(task) }>
						{ formatDueDate(task.DueDate) }
					</span>
				}
				if task.Recurrence != nil {
					<span class="text-xs text-gray-400">recurrente</span>
				}
				for _, label := range task.Labels {
					<span class={ "text-xs px-1.5 py-0.5 rounded " + label.BadgeClass() }>
						{ label.Name }
					</span>
				}
			</div>
		</div>

		<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
			<a href={ templ.SafeURL("/tasks/" + task.ID.String() + "/edit") }
			   class="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-400 text-xs">
				Editar
			</a>
			<button hx-delete={ "/tasks/" + task.ID.String() }
			        hx-target={ "#task-" + task.ID.String() }
			        hx-swap="outerHTML swap:0.2s"
			        hx-confirm="¿Eliminar esta tarea?"
			        class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400
			               hover:text-red-500 text-xs">
				Eliminar
			</button>
		</div>
	</div>
}

templ QuickAddForm(projectID *string) {
	<form hx-post="/tasks"
	      hx-target="#today-list"
	      hx-swap="beforeend"
	      @submit="this.reset()"
	      class="mb-8 flex items-center gap-3 px-3 py-2.5 border border-dashed border-gray-300
	             dark:border-gray-700 rounded-xl hover:border-blue-400 dark:hover:border-blue-600
	             focus-within:border-blue-500 transition-colors">
		<span class="w-4 h-4 text-gray-300 dark:text-gray-600 text-xl leading-none flex-shrink-0">+</span>
		<input type="text" name="title" placeholder="Agregar tarea..."
		       class="flex-1 bg-transparent outline-none text-sm text-gray-700 dark:text-gray-300
		              placeholder-gray-400 dark:placeholder-gray-600"/>
		if projectID != nil {
			<input type="hidden" name="project_id" value={ *projectID }/>
		}
		<input type="hidden" name="due_date" value="today"/>
	</form>
}

func taskRowClass(t domain.Task) string {
	if t.IsOverdue() {
		return "border-l-2 border-red-400 pl-2"
	}
	return ""
}

func checkboxStyle(t domain.Task) string {
	if t.Status.IsDone() {
		return "border-blue-500 bg-blue-500"
	}
	return "border-gray-300 dark:border-gray-600 hover:border-blue-500"
}

func taskTitleClass(t domain.Task) string {
	if t.Status.IsDone() {
		return "line-through text-gray-400 dark:text-gray-500"
	}
	return "text-gray-800 dark:text-gray-200"
}

func dueDateClass(t domain.Task) string {
	if t.IsOverdue() { return "text-red-500" }
	if t.IsDueToday() { return "text-blue-600 dark:text-blue-400" }
	return "text-gray-400"
}

func completionURL(t domain.Task) string {
	if t.Status.IsDone() {
		return "/tasks/" + t.ID.String() + "/uncomplete"
	}
	return "/tasks/" + t.ID.String() + "/complete"
}

func formatDueDate(d *time.Time) string {
	if d == nil { return "" }
	today := time.Now().Truncate(24 * time.Hour)
	due := d.Truncate(24 * time.Hour)
	switch {
	case due.Equal(today): return "Hoy"
	case due.Equal(today.AddDate(0, 0, 1)): return "Mañana"
	case due.Equal(today.AddDate(0, 0, -1)): return "Ayer"
	default: return d.Format("2 Jan")
	}
}

func todayDate() string {
	days := map[time.Weekday]string{
		time.Monday: "Lunes", time.Tuesday: "Martes", time.Wednesday: "Miércoles",
		time.Thursday: "Jueves", time.Friday: "Viernes",
		time.Saturday: "Sábado", time.Sunday: "Domingo",
	}
	months := map[time.Month]string{
		time.January: "enero", time.February: "febrero", time.March: "marzo",
		time.April: "abril", time.May: "mayo", time.June: "junio",
		time.July: "julio", time.August: "agosto", time.September: "septiembre",
		time.October: "octubre", time.November: "noviembre", time.December: "diciembre",
	}
	now := time.Now()
	return fmt.Sprintf("%s, %d de %s", days[now.Weekday()], now.Day(), months[now.Month()])
}

Vista de Calendario Mensual

// internal/view/calendar/month.templ
package calendar

import (
	"fmt"
	"time"
	"planify/internal/domain"
	"planify/internal/view"
)

templ MonthPage(user *domain.User, projects []domain.Project, cal *domain.CalendarMonth) {
	@view.Layout("Calendario", user, projects) {
		<div class="flex h-full">
			<!-- Grilla del calendario -->
			<div class="flex-1 flex flex-col p-6">
				<div class="flex items-center justify-between mb-6">
					<h2 class="text-xl font-bold">{ cal.Title() }</h2>
					<div class="flex items-center gap-2">
						@monthNavButton(cal, -1, "Anterior")
						<a href="/calendar"
						   class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400
						          hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
							Hoy
						</a>
						@monthNavButton(cal, 1, "Siguiente")
					</div>
				</div>

				<!-- Encabezados de días -->
				<div class="grid grid-cols-7 mb-2">
					for _, day := range []string{"Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"} {
						<div class="text-xs font-medium text-gray-400 dark:text-gray-500 text-center py-1">
							{ day }
						</div>
					}
				</div>

				<!-- Grilla -->
				<div id="calendar-grid"
				     class="flex-1 grid grid-cols-7 grid-rows-6 gap-px bg-gray-200 dark:bg-gray-800 rounded-xl overflow-hidden">
					for _, day := range cal.Days {
						@CalendarCell(day, cal)
					}
				</div>
			</div>

			<!-- Panel de detalle del día -->
			<div id="day-panel"
			     class="w-72 flex-shrink-0 border-l border-gray-200 dark:border-gray-800
			            bg-white dark:bg-gray-900 overflow-y-auto">
				<div class="p-6 text-sm text-gray-400 dark:text-gray-500 text-center mt-8">
					Haz clic en un día para ver sus tareas
				</div>
			</div>
		</div>
	}
}

templ CalendarCell(day domain.CalendarDay, cal *domain.CalendarMonth) {
	<div class={ calCellClass(day) }
	     hx-get={ fmt.Sprintf("/calendar/day?date=%s", day.Date.Format("2006-01-02")) }
	     hx-target="#day-panel"
	     hx-swap="innerHTML">
		<div class="p-1.5">
			<span class={ calDayNumberClass(day) }>
				{ fmt.Sprintf("%d", day.Date.Day()) }
			</span>
		</div>
		<div class="px-1.5 pb-1.5 space-y-0.5">
			for i, task := range day.Tasks {
				if i < 3 {
					<div class={ "text-xs px-1 py-0.5 rounded truncate " + calTaskClass(task) }>
						{ task.Title }
					</div>
				}
			}
			if len(day.Tasks) > 3 {
				<div class="text-xs text-gray-400 px-1">
					{ fmt.Sprintf("+%d más", len(day.Tasks)-3) }
				</div>
			}
		</div>
	</div>
}

templ DayPanel(day domain.CalendarDay) {
	<div class="p-5">
		<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">
			{ day.Date.Format("2 de January, 2006") }
		</h3>

		if len(day.Tasks) == 0 {
			<p class="text-sm text-gray-400 dark:text-gray-500">Sin tareas</p>
		} else {
			<div class="space-y-2">
				for _, task := range day.Tasks {
					<div class={ "flex items-start gap-2 p-2 rounded-lg " + dayPanelTaskClass(task) }>
						<span class={ "w-1.5 h-1.5 rounded-full mt-1.5 flex-shrink-0 " + task.Priority.DotColor() }></span>
						<div class="flex-1 min-w-0">
							<p class={ "text-sm " + dayPanelTitleClass(task) }>{ task.Title }</p>
							if task.DueTime != nil {
								<p class="text-xs text-gray-400 mt-0.5">
									{ task.DueTime.Format("15:04") }
								</p>
							}
						</div>
						<button hx-post={ completionURLDay(task) }
						        hx-get={ fmt.Sprintf("/calendar/day?date=%s", task.DueDate.Format("2006-01-02")) }
						        hx-target="#day-panel"
						        hx-swap="innerHTML"
						        class="text-xs text-gray-400 hover:text-blue-600 flex-shrink-0 mt-0.5">
							{ doneLabel(task) }
						</button>
					</div>
				}
			</div>
		}

		<form hx-post="/tasks"
		      hx-get={ fmt.Sprintf("/calendar/day?date=%s", day.Date.Format("2006-01-02")) }
		      hx-target="#day-panel"
		      hx-swap="innerHTML"
		      @submit="this.reset()"
		      class="mt-4 flex gap-2">
			<input type="text" name="title" placeholder="Agregar tarea..."
			       required
			       class="flex-1 text-sm px-2 py-1.5 border border-gray-200 dark:border-gray-700
			              rounded-lg bg-transparent focus:ring-2 focus:ring-blue-500 outline-none"/>
			<input type="hidden" name="due_date" value={ day.Date.Format("2006-01-02") }/>
			<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>
}

func calCellClass(day domain.CalendarDay) string {
	base := "bg-white dark:bg-gray-900 min-h-24 cursor-pointer " +
		"hover:bg-blue-50 dark:hover:bg-blue-900/10 transition-colors"
	if !day.IsCurrentMonth {
		base += " opacity-40"
	}
	if day.IsToday {
		base += " ring-2 ring-inset ring-blue-500"
	}
	return base
}

func calDayNumberClass(day domain.CalendarDay) string {
	base := "inline-flex w-6 h-6 items-center justify-center rounded-full text-xs font-medium"
	if day.IsToday {
		return base + " bg-blue-600 text-white"
	}
	if day.IsWeekend {
		return base + " text-gray-400 dark:text-gray-500"
	}
	return base + " text-gray-700 dark:text-gray-300"
}

func calTaskClass(t domain.Task) string {
	if t.Status.IsDone() {
		return "bg-gray-100 dark:bg-gray-800 text-gray-400 line-through"
	}
	if t.IsOverdue() {
		return "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400"
	}
	return "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
}

func dayPanelTaskClass(t domain.Task) string {
	if t.IsOverdue() { return "bg-red-50 dark:bg-red-900/10" }
	return "bg-gray-50 dark:bg-gray-800"
}

func dayPanelTitleClass(t domain.Task) string {
	if t.Status.IsDone() { return "line-through text-gray-400" }
	return "text-gray-800 dark:text-gray-200"
}

func completionURLDay(t domain.Task) string {
	if t.Status.IsDone() {
		return "/tasks/" + t.ID.String() + "/uncomplete"
	}
	return "/tasks/" + t.ID.String() + "/complete"
}

func doneLabel(t domain.Task) string {
	if t.Status.IsDone() { return "Deshacer" }
	return "Hecho"
}

func monthNavButton(cal *domain.CalendarMonth, direction int, label string) templ.Component {
	var y int
	var m time.Month
	if direction < 0 {
		y, m = cal.PrevMonth()
	} else {
		y, m = cal.NextMonth()
	}
	return navButton(fmt.Sprintf("/calendar?year=%d&month=%d", y, int(m)), label)
}

templ navButton(href, label string) {
	<a href={ templ.SafeURL(href) }
	   hx-get={ href }
	   hx-target="body"
	   hx-push-url="true"
	   class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400
	          hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
		{ label }
	</a>
}

Pruebas de Dominio

// internal/domain/task_test.go
package domain

import (
	"testing"
	"time"

	"github.com/google/uuid"
)

func TestTask_IsOverdue(t *testing.T) {
	ayer := time.Now().AddDate(0, 0, -1)
	manana := time.Now().AddDate(0, 0, 1)

	tests := []struct {
		name     string
		dueDate  *time.Time
		status   Status
		expected bool
	}{
		{"sin fecha límite nunca está vencida", nil, StatusTodo, false},
		{"fecha pasada e incompleta está vencida", &ayer, StatusTodo, true},
		{"fecha pasada pero completada no está vencida", &ayer, StatusDone, false},
		{"fecha futura no está vencida", &manana, StatusTodo, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			task := &Task{ID: uuid.New(), DueDate: tt.dueDate, Status: tt.status}
			if got := task.IsOverdue(); got != tt.expected {
				t.Errorf("IsOverdue() = %v, quería %v", got, tt.expected)
			}
		})
	}
}

func TestRecurrenceRule_NextOccurrence(t *testing.T) {
	base := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)

	tests := []struct {
		name      string
		rule      RecurrenceRule
		from      time.Time
		wantYear  int
		wantMonth time.Month
		wantDay   int
		wantNil   bool
	}{
		{
			name:     "diaria cada 1 día",
			rule:     RecurrenceRule{Frequency: FreqDaily, Interval: 1},
			from:     base,
			wantYear: 2025, wantMonth: 6, wantDay: 2,
		},
		{
			name:     "semanal cada 1 semana",
			rule:     RecurrenceRule{Frequency: FreqWeekly, Interval: 1},
			from:     base,
			wantYear: 2025, wantMonth: 6, wantDay: 8,
		},
		{
			name:     "mensual cada 1 mes",
			rule:     RecurrenceRule{Frequency: FreqMonthly, Interval: 1},
			from:     base,
			wantYear: 2025, wantMonth: 7, wantDay: 1,
		},
		{
			name: "fecha de fin pasada devuelve nil",
			rule: func() RecurrenceRule {
				fin := base.AddDate(0, 0, -1)
				return RecurrenceRule{Frequency: FreqDaily, Interval: 1, EndDate: &fin}
			}(),
			from:    base,
			wantNil: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			next := tt.rule.NextOccurrence(tt.from)
			if tt.wantNil {
				if next != nil {
					t.Errorf("esperaba nil, obtuve %v", next)
				}
				return
			}
			if next == nil {
				t.Fatal("esperaba próxima ocurrencia no-nil")
			}
			if next.Year() != tt.wantYear || next.Month() != tt.wantMonth || next.Day() != tt.wantDay {
				t.Errorf("NextOccurrence() = %v, quería %d-%02d-%02d",
					next, tt.wantYear, int(tt.wantMonth), tt.wantDay)
			}
		})
	}
}

func TestProject_Progress(t *testing.T) {
	tests := []struct {
		total, done, expected int
	}{
		{0, 0, 0},
		{10, 10, 100},
		{10, 5, 50},
		{3, 1, 33},
	}
	for _, tt := range tests {
		p := &Project{TaskCount: tt.total, DoneCount: tt.done}
		if got := p.Progress(); got != tt.expected {
			t.Errorf("Progress(%d/%d) = %d, quería %d", tt.done, tt.total, got, tt.expected)
		}
	}
}

Docker y Despliegue

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: "postgres://planify:planify@db:5432/planify?sslmode=disable"
      SESSION_KEY: "reemplaza-con-cadena-aleatoria-de-64-chars-en-produccion"
      ENVIRONMENT: "development"
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: planify
      POSTGRES_PASSWORD: planify
      POSTGRES_DB: planify
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U planify"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:
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 /planify ./cmd/server

FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /planify .
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/static ./static
EXPOSE 8080
ENTRYPOINT ["./planify"]

go.mod y Makefile

module planify

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/jackc/pgx/v5 v5.7.2
	golang.org/x/crypto v0.38.0
)
.PHONY: dev build test templ tailwind migrate-up docker-up setup

dev:
	air

build: templ tailwind
	go build -ldflags="-s -w" -o bin/planify ./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:
	migrate -path migrations -database "$(DATABASE_URL)" up

migrate-down:
	migrate -path migrations -database "$(DATABASE_URL)" down 1

docker-up:
	docker compose up -d --build

setup:
	go install github.com/a-h/templ/cmd/templ@latest
	go install github.com/air-verse/air@latest
	go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
	npm install
	cp .env.example .env

Lo Que Este Proyecto Agrega a la Serie Full Stack Go

Cada proyecto de esta serie introduce patrones que los anteriores no necesitaron:

PatrónTask BoardNoteflowPlanify
PostgreSQL + pgxpoolNo (SQLite)
SQLite + moderncNoNo
SSE en tiempo realNoNo
Renderizado MarkdownNoNo
Búsqueda FTS5NoNo
Renderizado de grilla de calendario en TemplNoNo
Lógica de tareas recurrentes con reglas de dominioNoNo
Consultas SQL con rango de fechas e índices parcialesNoNo
JSONB para esquema flexible en PostgreSQLNoNo
Navegación multi-vista (dashboard/calendario/proyecto)NoNo
Panel de día como parcial HTMXNoNo
Índice parcial (WHERE status != 2)NoNo

La vista de calendario es el reto más interesante de Templ en la serie. Generar una grilla de 42 celdas desde un slice []CalendarDay, mapear tareas en celdas de días en un solo pase, calcular las clases CSS para hoy/fin de semana/otro-mes/vencido — todo ocurre en funciones Go puras que Templ llama en tiempo de compilación. Sin framework JavaScript, sin librería de fechas, sin widget de calendario del lado del cliente.

La medida de un framework full stack no es lo que puede hacer de forma aislada. Es lo que puede hacer en toda la superficie de una aplicación real — autenticación, modelado de datos, diseño de consultas, renderizado de templates e interacción del cliente — todo en un modelo mental consistente. Go con HTMX y Templ pasa esa prueba.

Tags

#golang #go #full-stack #architecture #postgresql #performance #security #best-practices #guide