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.
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
}
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
}
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.
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
}
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.
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.
Questions about KaryoSpace or this article? We'd love to hear from you.