All articles

Building a project management tool in Go: sprints, kanban, burndown charts, and AI task creation

Suman Akkisetty
Founder, KaryoSpace
April 2026
10 min read

KaryoSpace includes a full project management module: projects, epics, sprints, tasks with kanban view, sprint burndown charts, and AI task creation from natural language. It replaced Jira for internal use before the first external customer signed up. Here is how the sprint lifecycle, burndown, and AI creation work under the hood.

Sprint lifecycle

A sprint moves through four states: planned, active, closed, and cancelled. Only one sprint per project can be active at a time. The state machine is enforced at the API level, not just the UI level.

type Sprint struct {
    ID        primitive.ObjectID `bson:"_id"`
    ProjectID primitive.ObjectID `bson:"project_id"`
    OrgID     string             `bson:"org_id"`
    Name      string             `bson:"name"`
    Status    string             `bson:"status"` // planned|active|closed|cancelled
    StartDate time.Time          `bson:"start_date"`
    EndDate   time.Time          `bson:"end_date"`
    Goals     []string           `bson:"goals"`
    CreatedAt time.Time          `bson:"created_at"`
}

func startSprint(sprintID, projectID, orgID string) error {
    // Ensure no active sprint exists for this project
    count, _ := db.Collection("pm_sprints").CountDocuments(ctx,
        bson.M{"project_id": projectID, "org_id": orgID, "status": "active"})
    if count > 0 {
        return errors.New("a sprint is already active for this project")
    }
    _, err := db.Collection("pm_sprints").UpdateOne(ctx,
        bson.M{"_id": sprintID, "org_id": orgID, "status": "planned"},
        bson.M{"$set": bson.M{"status": "active", "started_at": time.Now()}})
    return err
}

Task types and story points

Tasks follow the Jira taxonomy: story, task, bug, subtask. Story points use the Fibonacci sequence (1, 2, 3, 5, 8, 13, 21). The sequence is enforced by a validation function on create and update, not just in the UI picker. A story point value of 6 returns a 400.

var validStoryPoints = map[int]bool{
    1: true, 2: true, 3: true, 5: true, 8: true, 13: true, 21: true,
}

func validateStoryPoints(points int) error {
    if points == 0 { return nil } // unestimated is fine
    if !validStoryPoints[points] {
        return fmt.Errorf("story points must be Fibonacci: 1,2,3,5,8,13,21")
    }
    return nil
}

Sprint burndown chart

The burndown endpoint returns data for two lines: ideal and actual. Ideal is a straight line from total story points on day 0 to 0 on the sprint end date. Actual is the real remaining points each day, calculated by looking at which tasks were completed and when.

// GET /projects/api/sprints/{id}/burndown
func sprintBurndownHandler(w http.ResponseWriter, r *http.Request) {
    sprint := getSprint(sprintID)
    tasks := getSprintTasks(sprintID)
    totalPoints := sumStoryPoints(tasks)
    days := int(sprint.EndDate.Sub(sprint.StartDate).Hours()/24) + 1

    ideal := make([]float64, days)
    actual := make([]float64, days)
    for i := 0; i < days; i++ {
        ideal[i] = float64(totalPoints) * float64(days-1-i) / float64(days-1)
    }

    // actual[i] = points still open at end of day i
    for i, day := range eachDay(sprint.StartDate, days) {
        remaining := totalPoints
        for _, t := range tasks {
            if t.Status == "done" && !t.CompletedAt.IsZero() &&
               !t.CompletedAt.After(day) {
                remaining -= t.StoryPoints
            }
        }
        actual[i] = float64(remaining)
    }
    json.NewEncoder(w).Encode(BurndownData{Ideal: ideal, Actual: actual})
}

The Chart.js frontend renders both lines on the same canvas: a dashed violet line for ideal, a solid cyan line for actual. When actual is above ideal, the sprint is behind. When it crosses below, the team is ahead. The chart updates in real time as tasks are closed.

AI task creation

From the Home AI input, users can create tasks with natural language: "Create a task: fix the login timeout bug, assign to sarah, high priority, sprint 4." The AI command is classified, parsed, and dispatched to the task creation API. The response includes the task ID and a direct link to the kanban board.

// Slash command: /jira Create a bug: login fails on mobile, high priority
func handleJiraCreate(userID, orgID, text string) (*SlashResult, error) {
    prompt := fmt.Sprintf(`Extract task details from: "%s"
    Return JSON: {"title":"...","type":"bug|story|task","priority":"high|medium|low","description":"..."}`, text)

    resp, _ := llmProvider.Complete(ctx, []llm.Message{{Role:"user", Content:prompt}})
    var details TaskDetails
    json.Unmarshal([]byte(resp), &details)

    task := createTask(details, userID, orgID)
    storeUserQuery(userID, orgID, text, "jira_create")  // feeds KRE
    return &SlashResult{
        Text: fmt.Sprintf("Created %s: %s", task.Type, task.Title),
        URL:  fmt.Sprintf("/projects/%s/task/%s", task.ProjectID, task.ID),
    }, nil
}

Task dependency links

Tasks support three relationship types: blocks, blocked_by, and relates_to. These are stored as a links array on the task document. The kanban board renders a dependency indicator on blocked tasks, and the task detail view shows the full dependency graph.

type TaskLink struct {
    TargetID   string `bson:"target_id"`
    LinkType   string `bson:"link_type"` // blocks|blocked_by|relates_to
    LinkedAt   time.Time `bson:"linked_at"`
    LinkedByID string `bson:"linked_by_id"`
}

The link types are intentionally not bidirectional in the database. When a user marks task A as blocks task B, only task A gets the link. This avoids the concurrency problem of keeping two documents in sync. The UI resolves the inverse relationship at query time: when displaying task B, the backend queries for tasks that have a blocks link pointing to B's ID.

Overdue alerts on the Home assistant

The AI home assistant includes a section for overdue tasks assigned to the current user. This runs as part of the parallel feed fetch at page load, with an 8-second timeout. A task is overdue if its due date is in the past and its status is not done or cancelled. The query is indexed on assignee_id + due_date + status and runs in under 5ms on a typical sprint.

Get in touch

Questions about KaryoSpace or this article? We'd love to hear from you.

Contact Us
Message sent!
We read every submission and will get back to you.