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.
Tags
Related Articles
Organizational Health Through Architecture: Building Alignment, Trust & Healthy Culture
Learn how architecture decisions shape organizational culture, health, and alignment. Discover how to use architecture as a tool for building trust, preventing silos, enabling transparency, and creating sustainable organizational growth.
Team Health & Burnout Prevention: How Architecture Decisions Impact Human Well-being
Master the human side of architecture. Learn to recognize burnout signals, architect sustainable systems, build psychological safety, and protect team health. Because healthy teams build better systems.
Difficult Conversations & Conflict Resolution: Navigating Disagreement, Politics & Defensive Teams
Master the art of having difficult conversations as an architect. Learn how to manage technical disagreements, handle defensive teams, say no effectively, and navigate organizational politics without damaging relationships.