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.
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
| Capa | Tecnología | Por qué |
|---|---|---|
| Router | Chi v5 | Middleware, parámetros URL, grupos de rutas |
| Templates | Templ | Type-safe, compilado, soporte IDE |
| Reactividad | HTMX | Intercambios dirigidos por servidor, sin overhead SPA |
| Estado cliente | Alpine.js | Navegación de calendario, date pickers, dropdowns |
| Estilos | Tailwind CSS | Utility-first, responsivo, modo oscuro |
| Base de datos | PostgreSQL 17 + pgx v5 | Aritmética de fechas, consultas de tareas recurrentes |
| Auth | bcrypt + cookies HTTP-only | Basada en sesiones, sin complejidad JWT |
| Migraciones | golang-migrate | Cambios 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ón | Task Board | Noteflow | Planify |
|---|---|---|---|
| PostgreSQL + pgxpool | Sí | No (SQLite) | Sí |
| SQLite + modernc | No | Sí | No |
| SSE en tiempo real | Sí | No | No |
| Renderizado Markdown | No | Sí | No |
| Búsqueda FTS5 | No | Sí | No |
| Renderizado de grilla de calendario en Templ | No | No | Sí |
| Lógica de tareas recurrentes con reglas de dominio | No | No | Sí |
| Consultas SQL con rango de fechas e índices parciales | No | No | Sí |
| JSONB para esquema flexible en PostgreSQL | No | No | Sí |
| Navegación multi-vista (dashboard/calendario/proyecto) | No | No | Sí |
| Panel de día como parcial HTMX | No | No | Sí |
| Índice parcial (WHERE status != 2) | No | No | Sí |
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
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.