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.
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
| Layer | Technology | Why |
|---|---|---|
| Router | Chi v5 | Middleware, URL params, route groups |
| Templates | Templ | Type-safe, compiled, IDE support |
| Reactivity | HTMX | Server-driven swaps, no SPA overhead |
| Client state | Alpine.js | Calendar navigation, date pickers, dropdowns |
| Styling | Tailwind CSS | Utility-first, responsive, dark mode |
| Database | PostgreSQL 17 + pgx v5 | Date arithmetic, recurring task queries |
| Auth | bcrypt + HTTP-only cookies | Session-based, no JWT complexity |
| Migrations | golang-migrate | Versioned 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:
| Pattern | Task Board | Noteflow | Planify |
|---|---|---|---|
| PostgreSQL + pgxpool | Yes | No (SQLite) | Yes |
| SQLite + modernc | No | Yes | No |
| SSE real-time broadcast | Yes | No | No |
| Markdown rendering | No | Yes | No |
| FTS5 full-text search | No | Yes | No |
| Calendar grid rendering in Templ | No | No | Yes |
| Recurring task logic with domain rules | No | No | Yes |
| Date-range SQL queries with indexes | No | No | Yes |
| JSONB for flexible schema in PostgreSQL | No | No | Yes |
| Multi-view navigation (dashboard/calendar/project) | No | No | Yes |
| Day panel as HTMX partial | No | No | Yes |
| Partial index (WHERE status != 2) | No | No | Yes |
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.
Related articles
By relevance
Full Stack Go: Build a Real-Time Task Board with HTMX, Templ and Alpine.js
Build a complete task board in Go with HTMX for reactivity, Templ for type-safe templates, Alpine.js, auth, PostgreSQL, and SSE for live updates.
Full Stack Go: Notes and Todos App with HTMX, Templ and SQLite
Build a complete Notes and Todos app in Go: markdown notes with tags, todo checklists, full-text search, auth, HTMX reactivity, Templ templates, and SQLite.
Go 1.25 Medical API: DDD, TDD, BDD, PostgreSQL, Redis β Complete Clinical System
Build a production-ready medical records API in Go 1.25 with DDD, TDD, BDD, Gherkin scenarios, PostgreSQL, Redis. Full patient history, clinical boards, and every file explained.
Go 1.25 Production API: DDD, TDD, BDD, PostgreSQL, Redis β Complete Project Setup
Build a production-ready Go 1.25 REST API from scratch. Full DDD architecture, TDD/BDD testing, PostgreSQL, Redis caching, Chi router, and every file explained with clean code patterns.