Full Stack Go: Task Planner with Calendar, Projects and Recurring Tasks

Full Stack Go: Task Planner with Calendar, Projects and Recurring Tasks

Build Planify in Go: a full task planner with calendar view, projects, labels, recurring tasks, today dashboard, HTMX, Templ, Tailwind, PostgreSQL and auth.

By Omar Flores

Full Stack Go: Task Planner with Calendar, Projects and Recurring Tasks

A calendar is a machine for making time visible. Every task planner that works is solving the same fundamental problem: help a person see what they have committed to, when, and whether those commitments are realistic.

This guide builds Planify — a task planner with a today dashboard, a monthly calendar view, project-based organization, labels, priorities, recurring tasks, and real-time updates via HTMX. The entire stack is Go: PostgreSQL for persistence (recurring tasks require real date arithmetic), Templ for type-safe server-rendered HTML, HTMX for mutations without page reloads, Alpine.js for local UI state, and Tailwind CSS for the interface.

This project introduces patterns the previous full-stack Go posts did not cover: calendar grid rendering in Templ, recurring task expansion logic, date-aware SQL queries, and a multi-view navigation (dashboard, calendar, project board).


The Stack

LayerTechnologyWhy
RouterChi v5Middleware, URL params, route groups
TemplatesTemplType-safe, compiled, IDE support
ReactivityHTMXServer-driven swaps, no SPA overhead
Client stateAlpine.jsCalendar navigation, date pickers, dropdowns
StylingTailwind CSSUtility-first, responsive, dark mode
DatabasePostgreSQL 17 + pgx v5Date arithmetic, recurring task queries
Authbcrypt + HTTP-only cookiesSession-based, no JWT complexity
Migrationsgolang-migrateVersioned SQL schema changes

Project Structure

planify/
  cmd/
    server/
      main.go
  internal/
    config/
      config.go
    domain/
      task.go           # Task, RecurrenceRule, Priority, Status
      project.go        # Project entity
      label.go          # Label entity
      user.go           # User, Session
      errors.go
      calendar.go       # CalendarDay, CalendarMonth value objects
    auth/
      repository.go
      service.go
      middleware.go
    task/
      repository.go     # CRUD, date queries, recurring expansion
      service.go        # Recurrence logic, today/upcoming/overdue
    project/
      repository.go
      service.go
    handler/
      auth.go
      task.go           # Task CRUD + completion toggle
      calendar.go       # Calendar view handler
      dashboard.go      # Today + upcoming + overdue
      project.go
    view/
      layout.templ      # Base layout with sidebar nav
      auth.templ
      dashboard/
        index.templ     # Today, upcoming, overdue sections
      calendar/
        month.templ     # Monthly grid
        day.templ       # Day detail panel (HTMX partial)
      task/
        form.templ      # Create / edit with recurrence options
        card.templ      # Task card component
        list.templ      # Flat list view
      project/
        list.templ
        detail.templ    # Project with task board
      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

Domain Layer

The domain contains the core concepts: a task has a due date, a priority, a status, an optional project, zero or more labels, and an optional recurrence rule. The recurrence rule is stored as structured data — not as a string you parse at runtime.

// 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       // optional time-of-day
	Labels      []Label
	Recurrence  *RecurrenceRule
	ParentID    *uuid.UUID       // non-nil for recurring instances
	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 "Low"
	case PriorityMedium:
		return "Medium"
	case PriorityHigh:
		return "High"
	case PriorityUrgent:
		return "Urgent"
	default:
		return "None"
	}
}

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 (continued — RecurrenceRule)

type RecurrenceFrequency string

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

type RecurrenceRule struct {
	Frequency  RecurrenceFrequency
	Interval   int       // every N (days/weeks/months/years)
	DaysOfWeek []int     // 0=Sun, 1=Mon ... for weekly recurrence
	EndDate    *time.Time
	MaxCount   *int
}

// NextOccurrence returns the next due date after `from` based on this rule.
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 represents a single day cell in the monthly calendar grid.
type CalendarDay struct {
	Date        time.Time
	Tasks       []Task
	IsToday     bool
	IsCurrentMonth bool
	IsWeekend   bool
}

// CalendarMonth is the full grid for a monthly calendar view.
type CalendarMonth struct {
	Year  int
	Month time.Month
	Days  []CalendarDay // always 35 or 42 cells (5 or 6 rows of 7)
}

func (m *CalendarMonth) Title() string {
	return time.Date(m.Year, m.Month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006")
}

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   // Tailwind color name: "blue", "green", etc.
	TaskCount int      // total tasks (populated on fetch)
	DoneCount int      // completed tasks
	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"
}
// internal/domain/label.go
package domain

import "github.com/google/uuid"

type Label struct {
	ID     uuid.UUID
	UserID uuid.UUID
	Name   string
	Color  string
}

func (l Label) BadgeClass() string {
	return "bg-" + l.Color + "-100 text-" + l.Color + "-700 " +
		"dark:bg-" + l.Color + "-900/30 dark:text-" + l.Color + "-400"
}

Database Schema

Four migration files. Tasks reference projects and support parent-child relationships for recurring instances.

-- 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,
    -- Recurrence stored as JSONB for flexibility
    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);
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);

The recurrence JSONB column stores the RecurrenceRule serialized as JSON. This avoids adding recurrence-specific columns to the tasks table and lets you evolve the recurrence format without a schema migration.

The partial index WHERE status != 2 on due_date means the date index only covers incomplete tasks. Completed tasks are never queried by due date in the hot path.


Task Repository

The repository handles the date-range queries that power the dashboard and calendar views.

// 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 returns all tasks due today for the user.
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 returns all incomplete tasks with a due date before today.
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 returns tasks due in the next N days (excluding today).
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 returns all tasks in a date range (for calendar view).
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,
	)
}

// GetByProject returns all tasks for a project.
func (r *Repository) GetByProject(ctx context.Context, userID, projectID uuid.UUID) ([]domain.Task, error) {
	return r.queryTasks(ctx, userID,
		`WHERE t.user_id = $1 AND t.project_id = $2
		 ORDER BY t.status ASC, t.priority DESC, t.due_date ASC NULLS LAST`,
		userID, projectID,
	)
}

// Create inserts a new task and returns it with its generated ID.
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("marshal recurrence: %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 marks a task as done and sets completed_at.
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 reverts a task to todo.
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 removes a task and all its recurring children.
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
}

// GetByID fetches a single task with labels.
func (r *Repository) GetByID(ctx context.Context, taskID, userID uuid.UUID) (*domain.Task, error) {
	tasks, err := r.queryTasks(ctx, userID,
		`WHERE t.id = $1 AND t.user_id = $2`,
		taskID, userID,
	)
	if err != nil {
		return nil, err
	}
	if len(tasks) == 0 {
		return nil, domain.ErrNotFound
	}
	return &tasks[0], nil
}

// queryTasks is the single query builder for all task fetches.
// It joins labels and loads recurrence from JSONB.
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("query tasks: %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("scan task: %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
	}

	// Load labels for all tasks in one query
	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
}

Task Service: Recurrence Logic

When a recurring task is completed, the service creates the next instance based on the recurrence rule. The user sees a new task appear on the next due date automatically.

// 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) {
	// Calculate the date range for the calendar grid
	// (always starts on Sunday, includes days from adjacent months)
	firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
	lastOfMonth := firstOfMonth.AddDate(0, 1, -1)

	// Grid start: the Sunday on or before the first of the month
	gridStart := firstOfMonth
	for gridStart.Weekday() != time.Sunday {
		gridStart = gridStart.AddDate(0, 0, -1)
	}

	// Grid end: always show 6 full weeks (42 cells)
	gridEnd := gridStart.AddDate(0, 0, 41)

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

	// Index tasks by date string for O(1) lookup
	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)
	}

	// Pad to exactly 42 cells if needed
	_ = lastOfMonth

	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 marks a task as done. If it is recurring, schedules the next instance.
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
	}

	// If the task has a recurrence rule and a due date, spawn the next instance
	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
}

func (s *Service) UncompleteTask(ctx context.Context, taskID, userID uuid.UUID) error {
	return s.repo.Uncomplete(ctx, taskID, userID)
}

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

The CompleteTask method is the heart of the recurrence logic. When a recurring task is completed, it calls NextOccurrence on the rule, and if a valid next date exists, creates a new task with ParentID pointing to the completed task. The chain of instances is preserved in the database via the parent_id foreign key.


Templates

Base Layout

// internal/view/layout.templ
package view

import "planify/internal/domain"

templ Layout(title string, user *domain.User, projects []domain.Project) {
	<!DOCTYPE html>
	<html lang="en" 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">
		<!-- Sidebar -->
		<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">
			<!-- Logo -->
			<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>

			<!-- Main nav -->
			<nav class="px-3 py-4 space-y-0.5">
				@NavItem("/", "Today", "today", title == "Today")
				@NavItem("/upcoming", "Upcoming", "upcoming", title == "Upcoming")
				@NavItem("/calendar", "Calendar", "calendar", title == "Calendar")
				@NavItem("/inbox", "Inbox", "inbox", title == "Inbox")
			</nav>

			<!-- Projects -->
			<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">
						Projects
					</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>

			<!-- User footer -->
			<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">
					<span x-show="!dark" class="text-sm">D</span>
					<span x-show="dark" class="text-sm">L</span>
				</button>
				<form hx-post="/logout">
					<button type="submit" class="text-xs text-gray-400 hover:text-red-500">Out</button>
				</form>
			</div>
		</aside>

		<!-- Main -->
		<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 "T"
	case "upcoming":
		return "U"
	case "calendar":
		return "C"
	case "inbox":
		return "I"
	default:
		return "·"
	}
}

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

Adding the missing fmt import:

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

Today Dashboard

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

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

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

			<!-- Quick add -->
			@QuickAddForm(nil)

			<!-- Overdue -->
			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>
						Overdue ({ fmt.Sprintf("%d", len(overdue)) })
					</h2>
					<div class="space-y-1" id="overdue-list">
						for _, task := range overdue {
							@TaskRow(task)
						}
					</div>
				</section>
			}

			<!-- Today's tasks -->
			<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>
					Today ({ 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">
							All clear for today.
						</p>
					}
					for _, task := range today {
						@TaskRow(task)
					}
				</div>
			</section>

			<!-- Upcoming -->
			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>
						Next 7 days
					</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() }>
		<!-- Completion checkbox -->
		<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>

		<!-- Content -->
		<div class="flex-1 min-w-0">
			<div class="flex items-center gap-2">
				<!-- Priority dot -->
				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>

			<!-- Meta row -->
			<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">recurring</span>
				}
				for _, label := range task.Labels {
					<span class={ "text-xs px-1.5 py-0.5 rounded " + label.BadgeClass() }>
						{ label.Name }
					</span>
				}
			</div>
		</div>

		<!-- Row actions (visible on hover) -->
		<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">
				Edit
			</a>
			<button hx-delete={ "/tasks/" + task.ID.String() }
			        hx-target={ "#task-" + task.ID.String() }
			        hx-swap="outerHTML swap:0.2s"
			        hx-confirm="Delete this task?"
			        class="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-400
			               hover:text-red-500 text-xs">
				Del
			</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="Add task..."
		       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 "Today"
	case due.Equal(today.AddDate(0, 0, 1)):
		return "Tomorrow"
	case due.Equal(today.AddDate(0, 0, -1)):
		return "Yesterday"
	default:
		return d.Format("Jan 2")
	}
}

func todayDate() string {
	return time.Now().Format("Monday, January 2")
}

Monthly Calendar View

The calendar renders a 6x7 grid. Each cell is a CalendarDay. HTMX fetches the day’s task detail panel when a user clicks a cell.

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

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

templ MonthPage(user *domain.User, projects []domain.Project, cal *domain.CalendarMonth) {
	@view.Layout("Calendar", user, projects) {
		<div class="flex h-full">
			<!-- Calendar grid -->
			<div class="flex-1 flex flex-col p-6">
				<!-- Navigation header -->
				<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, "Previous")
						<button hx-get="/calendar"
						        hx-target="#calendar-grid"
						        hx-swap="innerHTML"
						        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">
							Today
						</button>
						@monthNavButton(cal, 1, "Next")
					</div>
				</div>

				<!-- Day-of-week headers -->
				<div class="grid grid-cols-7 mb-2">
					for _, day := range []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} {
						<div class="text-xs font-medium text-gray-400 dark:text-gray-500 text-center py-1">
							{ day }
						</div>
					}
				</div>

				<!-- Calendar grid -->
				<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>

			<!-- Day detail panel (HTMX target) -->
			<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">
					Click a day to see tasks
				</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">
		<!-- Date number -->
		<div class="p-1.5">
			<span class={ calDayNumberClass(day) }>
				{ fmt.Sprintf("%d", day.Date.Day()) }
			</span>
		</div>

		<!-- Task dots / previews -->
		<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 more", 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("Monday, January 2") }
		</h3>

		if len(day.Tasks) == 0 {
			<p class="text-sm text-gray-400 dark:text-gray-500">No tasks</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("3:04 PM") }
								</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>
		}

		<!-- Add to this day -->
		<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="Add task..."
			       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">
				Add
			</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 "Undo"
	}
	return "Done"
}

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>
}

Task Form with Recurrence

// internal/view/task/form.templ
package task

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

templ FormPage(user *domain.User, projects []domain.Project, labels []domain.Label, task *domain.Task, isEdit bool) {
	@view.Layout(formTitle(isEdit), user, projects) {
		<div class="max-w-xl mx-auto px-6 py-8"
		     x-data="{ hasRecurrence: false, recurrenceType: 'daily' }">
			<h1 class="text-xl font-bold mb-6">{ formTitle(isEdit) }</h1>

			<form hx-post={ formAction(task, isEdit) }
			      hx-push-url="true"
			      class="space-y-5">
				<!-- Title -->
				<div>
					<label class="block text-sm font-medium mb-1">Title</label>
					<input type="text" name="title" value={ taskTitle(task) } required
					       class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
					              bg-white dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none"/>
				</div>

				<!-- Description -->
				<div>
					<label class="block text-sm font-medium mb-1">Description</label>
					<textarea name="description" rows="3"
					          class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
					                 bg-white dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none resize-none">
						{ taskDesc(task) }
					</textarea>
				</div>

				<!-- Row: Priority + Status -->
				<div class="grid grid-cols-2 gap-4">
					<div>
						<label class="block text-sm font-medium mb-1">Priority</label>
						<select name="priority"
						        class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
						               bg-white dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none">
							<option value="0">None</option>
							<option value="1" if taskPriority(task) == "1" { selected }>Low</option>
							<option value="2" if taskPriority(task) == "2" { selected }>Medium</option>
							<option value="3" if taskPriority(task) == "3" { selected }>High</option>
							<option value="4" if taskPriority(task) == "4" { selected }>Urgent</option>
						</select>
					</div>
					<div>
						<label class="block text-sm font-medium mb-1">Project</label>
						<select name="project_id"
						        class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
						               bg-white dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none">
							<option value="">No project</option>
							for _, p := range projects {
								<option value={ p.ID.String() } if taskProjectID(task) == p.ID.String() { selected }>
									{ p.Name }
								</option>
							}
						</select>
					</div>
				</div>

				<!-- Row: Due date + Due time -->
				<div class="grid grid-cols-2 gap-4">
					<div>
						<label class="block text-sm font-medium mb-1">Due date</label>
						<input type="date" name="due_date" value={ taskDueDate(task) }
						       class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
						              bg-white dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none"/>
					</div>
					<div>
						<label class="block text-sm font-medium mb-1">Due time (optional)</label>
						<input type="time" name="due_time" value={ taskDueTime(task) }
						       class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
						              bg-white dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none"/>
					</div>
				</div>

				<!-- Labels -->
				<div>
					<label class="block text-sm font-medium mb-2">Labels</label>
					<div class="flex flex-wrap gap-2">
						for _, l := range labels {
							<label class="flex items-center gap-1.5 cursor-pointer">
								<input type="checkbox" name="label_ids" value={ l.ID.String() }
								       if taskHasLabel(task, l.ID.String()) { checked }
								       class="w-3.5 h-3.5 rounded accent-blue-600"/>
								<span class={ "text-xs px-2 py-0.5 rounded " + l.BadgeClass() }>
									{ l.Name }
								</span>
							</label>
						}
					</div>
				</div>

				<!-- Recurrence toggle -->
				<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-4">
					<div class="flex items-center gap-3">
						<input type="checkbox" id="has-recurrence" x-model="hasRecurrence"
						       class="w-4 h-4 rounded accent-blue-600"/>
						<label for="has-recurrence" class="text-sm font-medium">Repeat this task</label>
					</div>

					<div x-show="hasRecurrence" x-transition class="mt-4 space-y-3">
						<div class="grid grid-cols-2 gap-3">
							<div>
								<label class="block text-xs font-medium mb-1 text-gray-500">Frequency</label>
								<select name="recurrence_frequency" x-model="recurrenceType"
								        class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700
								               rounded-lg bg-white dark:bg-gray-900 text-sm focus:ring-2
								               focus:ring-blue-500 outline-none">
									<option value="daily">Daily</option>
									<option value="weekly">Weekly</option>
									<option value="monthly">Monthly</option>
									<option value="yearly">Yearly</option>
								</select>
							</div>
							<div>
								<label class="block text-xs font-medium mb-1 text-gray-500">
									Every N <span x-text="recurrenceType + 's'"></span>
								</label>
								<input type="number" name="recurrence_interval" min="1" value="1"
								       class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700
								              rounded-lg bg-white dark:bg-gray-900 text-sm focus:ring-2
								              focus:ring-blue-500 outline-none"/>
							</div>
						</div>

						<div>
							<label class="block text-xs font-medium mb-1 text-gray-500">End date (optional)</label>
							<input type="date" name="recurrence_end_date"
							       class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700
							              rounded-lg bg-white dark:bg-gray-900 text-sm focus:ring-2
							              focus:ring-blue-500 outline-none"/>
						</div>
					</div>
				</div>

				<!-- Actions -->
				<div class="flex items-center gap-3 pt-2">
					<button type="submit"
					        class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium">
						{ formSubmit(isEdit) }
					</button>
					<a href="/"
					   class="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
						Cancel
					</a>
				</div>
			</form>
		</div>
	}
}

func formTitle(isEdit bool) string {
	if isEdit { return "Edit Task" }
	return "New Task"
}

func formSubmit(isEdit bool) string {
	if isEdit { return "Save changes" }
	return "Create task"
}

func formAction(task *domain.Task, isEdit bool) string {
	if isEdit && task != nil {
		return "/tasks/" + task.ID.String()
	}
	return "/tasks"
}

func taskTitle(t *domain.Task) string {
	if t != nil { return t.Title }
	return ""
}

func taskDesc(t *domain.Task) string {
	if t != nil { return t.Description }
	return ""
}

func taskPriority(t *domain.Task) string {
	if t != nil { return fmt.Sprintf("%d", int(t.Priority)) }
	return "0"
}

func taskProjectID(t *domain.Task) string {
	if t != nil && t.ProjectID != nil { return t.ProjectID.String() }
	return ""
}

func taskDueDate(t *domain.Task) string {
	if t != nil && t.DueDate != nil { return t.DueDate.Format("2006-01-02") }
	return ""
}

func taskDueTime(t *domain.Task) string {
	if t != nil && t.DueTime != nil { return t.DueTime.Format("15:04") }
	return ""
}

func taskHasLabel(t *domain.Task, labelID string) bool {
	if t == nil { return false }
	for _, l := range t.Labels {
		if l.ID.String() == labelID { return true }
	}
	return false
}

Adding the missing import:

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

Handlers

Task Handler

// internal/handler/task.go
package handler

import (
	"net/http"
	"strconv"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"

	"planify/internal/auth"
	"planify/internal/domain"
	tasksvc "planify/internal/task"
	viewtask "planify/internal/view/task"
	viewdash "planify/internal/view/dashboard"
)

type TaskHandler struct {
	service *tasksvc.Service
}

func NewTaskHandler(service *tasksvc.Service) *TaskHandler {
	return &TaskHandler{service: service}
}

func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid form", http.StatusBadRequest)
		return
	}

	task := &domain.Task{
		UserID:      user.ID,
		Title:       r.FormValue("title"),
		Description: r.FormValue("description"),
		Priority:    parsePriority(r.FormValue("priority")),
		Status:      domain.StatusTodo,
	}

	// Project
	if pid := r.FormValue("project_id"); pid != "" {
		if id, err := uuid.Parse(pid); err == nil {
			task.ProjectID = &id
		}
	}

	// Due date
	if ds := r.FormValue("due_date"); ds == "today" {
		now := time.Now()
		task.DueDate = &now
	} else if ds != "" {
		if t, err := time.Parse("2006-01-02", ds); err == nil {
			task.DueDate = &t
		}
	}

	// Due time
	if ts := r.FormValue("due_time"); ts != "" {
		if t, err := time.Parse("15:04", ts); err == nil {
			task.DueTime = &t
		}
	}

	// Recurrence
	if freq := r.FormValue("recurrence_frequency"); freq != "" {
		interval, _ := strconv.Atoi(r.FormValue("recurrence_interval"))
		if interval < 1 {
			interval = 1
		}
		rec := &domain.RecurrenceRule{
			Frequency: domain.RecurrenceFrequency(freq),
			Interval:  interval,
		}
		if endDate := r.FormValue("recurrence_end_date"); endDate != "" {
			if t, err := time.Parse("2006-01-02", endDate); err == nil {
				rec.EndDate = &t
			}
		}
		task.Recurrence = rec
	}

	if err := h.service.CreateTask(r.Context(), task); err != nil {
		http.Error(w, "failed to create task", http.StatusInternalServerError)
		return
	}

	// If HTMX request, return just the task row
	if r.Header.Get("HX-Request") == "true" {
		viewtask.TaskRowPartial(*task).Render(r.Context(), w)
		return
	}

	w.Header().Set("HX-Redirect", "/")
	w.WriteHeader(http.StatusOK)
}

func (h *TaskHandler) Complete(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	taskID, err := uuid.Parse(chi.URLParam(r, "taskID"))
	if err != nil {
		http.Error(w, "invalid task ID", http.StatusBadRequest)
		return
	}

	if err := h.service.CompleteTask(r.Context(), taskID, user.ID); err != nil {
		http.Error(w, "failed to complete task", http.StatusInternalServerError)
		return
	}

	// Return the updated task row
	task, err := h.service.GetTask(r.Context(), taskID, user.ID)
	if err != nil {
		// Task might have been replaced by recurring instance — return empty (removes element)
		w.WriteHeader(http.StatusOK)
		return
	}

	viewtask.TaskRowPartial(*task).Render(r.Context(), w)
}

func (h *TaskHandler) Uncomplete(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	taskID, _ := uuid.Parse(chi.URLParam(r, "taskID"))

	if err := h.service.UncompleteTask(r.Context(), taskID, user.ID); err != nil {
		http.Error(w, "failed to uncomplete task", http.StatusInternalServerError)
		return
	}

	task, err := h.service.GetTask(r.Context(), taskID, user.ID)
	if err != nil {
		w.WriteHeader(http.StatusOK)
		return
	}

	viewtask.TaskRowPartial(*task).Render(r.Context(), w)
}

func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	taskID, _ := uuid.Parse(chi.URLParam(r, "taskID"))

	if err := h.service.DeleteTask(r.Context(), taskID, user.ID); err != nil {
		http.Error(w, "failed to delete task", http.StatusInternalServerError)
		return
	}

	// Return empty — HTMX removes the element with the fade-out swap
	w.WriteHeader(http.StatusOK)
}

func parsePriority(s string) domain.Priority {
	n, _ := strconv.Atoi(s)
	p := domain.Priority(n)
	if p < domain.PriorityNone || p > domain.PriorityUrgent {
		return domain.PriorityNone
	}
	return p
}

Calendar Handler

// internal/handler/calendar.go
package handler

import (
	"net/http"
	"strconv"
	"time"

	"planify/internal/auth"
	tasksvc "planify/internal/task"
	projsvc "planify/internal/project"
	viewcal "planify/internal/view/calendar"
)

type CalendarHandler struct {
	taskService    *tasksvc.Service
	projectService *projsvc.Service
}

func NewCalendarHandler(ts *tasksvc.Service, ps *projsvc.Service) *CalendarHandler {
	return &CalendarHandler{taskService: ts, projectService: ps}
}

func (h *CalendarHandler) Month(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())

	now := time.Now()
	year := now.Year()
	month := now.Month()

	if y := r.URL.Query().Get("year"); y != "" {
		if n, err := strconv.Atoi(y); err == nil {
			year = n
		}
	}
	if m := r.URL.Query().Get("month"); m != "" {
		if n, err := strconv.Atoi(m); err == nil && n >= 1 && n <= 12 {
			month = time.Month(n)
		}
	}

	cal, err := h.taskService.GetCalendarMonth(r.Context(), user.ID, year, month)
	if err != nil {
		http.Error(w, "failed to load calendar", http.StatusInternalServerError)
		return
	}

	projects, _ := h.projectService.ListProjects(r.Context(), user.ID)
	viewcal.MonthPage(user, projects, cal).Render(r.Context(), w)
}

func (h *CalendarHandler) Day(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())

	dateStr := r.URL.Query().Get("date")
	date, err := time.Parse("2006-01-02", dateStr)
	if err != nil {
		http.Error(w, "invalid date", http.StatusBadRequest)
		return
	}

	tasks, err := h.taskService.GetTasksForDate(r.Context(), user.ID, date)
	if err != nil {
		http.Error(w, "failed to load day", http.StatusInternalServerError)
		return
	}

	day := domain.CalendarDay{
		Date:        date,
		Tasks:       tasks,
		IsToday:     date.Equal(time.Now().Truncate(24 * time.Hour)),
		IsCurrentMonth: date.Month() == time.Now().Month(),
	}

	viewcal.DayPanel(day).Render(r.Context(), w)
}

Router and Main

// cmd/server/main.go
package main

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

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/jackc/pgx/v5/pgxpool"

	"planify/internal/auth"
	"planify/internal/config"
	"planify/internal/handler"
	"planify/internal/project"
	"planify/internal/task"
)

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

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

	pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
	if err != nil {
		slog.Error("db connect", "err", err)
		os.Exit(1)
	}
	defer pool.Close()

	if err := pool.Ping(context.Background()); err != nil {
		slog.Error("db ping", "err", err)
		os.Exit(1)
	}
	slog.Info("database ready")

	// Dependencies
	authRepo := auth.NewRepository(pool)
	authService := auth.NewService(authRepo)

	taskRepo := task.NewRepository(pool)
	taskService := task.NewService(taskRepo)

	projectRepo := project.NewRepository(pool)
	projectService := project.NewService(projectRepo)

	// Handlers
	authHandler := handler.NewAuthHandler(authService)
	dashHandler := handler.NewDashboardHandler(taskService, projectService)
	taskHandler := handler.NewTaskHandler(taskService)
	calHandler := handler.NewCalendarHandler(taskService, projectService)
	projHandler := handler.NewProjectHandler(projectService, taskService)

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

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

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

	r.Group(func(r chi.Router) {
		r.Use(authService.Middleware)
		r.Post("/logout", authHandler.Logout)

		r.Get("/", dashHandler.Today)
		r.Get("/upcoming", dashHandler.Upcoming)

		// Tasks
		r.Get("/tasks/new", taskHandler.ShowNew)
		r.Post("/tasks", taskHandler.Create)
		r.Get("/tasks/{taskID}/edit", taskHandler.ShowEdit)
		r.Post("/tasks/{taskID}", taskHandler.Update)
		r.Delete("/tasks/{taskID}", taskHandler.Delete)
		r.Post("/tasks/{taskID}/complete", taskHandler.Complete)
		r.Post("/tasks/{taskID}/uncomplete", taskHandler.Uncomplete)

		// Calendar
		r.Get("/calendar", calHandler.Month)
		r.Get("/calendar/day", calHandler.Day)

		// Projects
		r.Get("/projects", projHandler.List)
		r.Get("/projects/new", projHandler.ShowNew)
		r.Post("/projects", projHandler.Create)
		r.Get("/projects/{projectID}", projHandler.Detail)
		r.Delete("/projects/{projectID}", projHandler.Delete)
	})

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

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

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

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

func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("X-XSS-Protection", "1; mode=block")
		w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
		if r.TLS != nil {
			w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
		}
		next.ServeHTTP(w, r)
	})
}

Domain Tests

// internal/domain/task_test.go
package domain

import (
	"testing"
	"time"

	"github.com/google/uuid"
)

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

	tests := []struct {
		name     string
		dueDate  *time.Time
		status   Status
		expected bool
	}{
		{"no due date is never overdue", nil, StatusTodo, false},
		{"past due and incomplete is overdue", &yesterday, StatusTodo, true},
		{"past due but done is not overdue", &yesterday, StatusDone, false},
		{"future due is not overdue", &tomorrow, 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, want %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:     "daily every 1 day",
			rule:     RecurrenceRule{Frequency: FreqDaily, Interval: 1},
			from:     base,
			wantYear: 2025, wantMonth: 6, wantDay: 2,
		},
		{
			name:     "weekly every 1 week",
			rule:     RecurrenceRule{Frequency: FreqWeekly, Interval: 1},
			from:     base,
			wantYear: 2025, wantMonth: 6, wantDay: 8,
		},
		{
			name:     "monthly every 1 month",
			rule:     RecurrenceRule{Frequency: FreqMonthly, Interval: 1},
			from:     base,
			wantYear: 2025, wantMonth: 7, wantDay: 1,
		},
		{
			name: "past end date returns nil",
			rule: func() RecurrenceRule {
				end := base.AddDate(0, 0, -1)
				return RecurrenceRule{Frequency: FreqDaily, Interval: 1, EndDate: &end}
			}(),
			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("expected nil, got %v", next)
				}
				return
			}
			if next == nil {
				t.Fatal("expected non-nil next occurrence")
			}
			if next.Year() != tt.wantYear || next.Month() != tt.wantMonth || next.Day() != tt.wantDay {
				t.Errorf("NextOccurrence() = %v, want %d-%02d-%02d",
					next, tt.wantYear, int(tt.wantMonth), tt.wantDay)
			}
		})
	}
}

func TestCalendarMonth_Title(t *testing.T) {
	m := CalendarMonth{Year: 2025, Month: time.June}
	if got := m.Title(); got != "June 2025" {
		t.Errorf("Title() = %q, want %q", got, "June 2025")
	}
}

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, want %d", tt.done, tt.total, got, tt.expected)
		}
	}
}

Docker Compose and Deployment

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: "postgres://planify:planify@db:5432/planify?sslmode=disable"
      SESSION_KEY: "replace-with-64-char-random-string-in-production"
      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"]

Makefile

.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

go.mod

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
)

What This Project Adds to the Full-Stack Go Series

Each project in this series introduces patterns the previous ones did not need:

PatternTask BoardNoteflowPlanify
PostgreSQL + pgxpoolYesNo (SQLite)Yes
SQLite + moderncNoYesNo
SSE real-time broadcastYesNoNo
Markdown renderingNoYesNo
FTS5 full-text searchNoYesNo
Calendar grid rendering in TemplNoNoYes
Recurring task logic with domain rulesNoNoYes
Date-range SQL queries with indexesNoNoYes
JSONB for flexible schema in PostgreSQLNoNoYes
Multi-view navigation (dashboard/calendar/project)NoNoYes
Day panel as HTMX partialNoNoYes
Partial index (WHERE status != 2)NoNoYes

The calendar view is the most interesting Templ challenge in the series. Generating a 42-cell grid from a []CalendarDay slice, mapping tasks into day cells in a single pass, computing the CSS classes for today/weekend/other-month/overdue — all of it happens in pure Go functions that Templ calls at compile time. No JavaScript framework, no date library, no client-side calendar widget.

The measure of a full-stack framework is not what it can do in isolation. It is what it can do across the whole surface of a real application — authentication, data modeling, query design, template rendering, and client interaction — all in one consistent mental model. Go with HTMX and Templ passes that test.

Tags

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