How Plane Manages Cycles for Sprint Planning: A Deep Dive into the Architecture

Plane uses a three-layer architecture—Django backend, TypeScript service layer, and MobX state management—to persist, synchronize, and reactively display sprint cycles across the entire application.

In open-source project management tools, cycle management forms the backbone of agile sprint planning. Plane, the self-hosted project management platform from makeplane/plane, implements this through a sophisticated system that spans Python's Django ORM, RESTful APIs, and a reactive TypeScript frontend. This article examines how Plane handles the complete lifecycle of a sprint cycle—from creation and storage to filtering, analytics, and archival—based on the actual source code implementation.

The Three-Layer Cycle Architecture

Plane's sprint planning system is organized into distinct layers, each with clear responsibilities and well-defined interfaces between them.

Backend Layer: Django Models and REST Endpoints

The foundation sits in Django, where cycles are defined as persistent database entities with rich relationships to projects, issues, and users.

Core Models in apps/api/plane/db/models/cycle.py

The Cycle model defines the essential sprint fields: name, start_date, end_date, status, and progress_snapshot. Related models handle specific concerns:

  • CycleIssue — links individual issues to specific cycles
  • CycleUserProperties — stores per-user preferences and favorite status

Serialization and API Surface

The CycleBaseSerializer in apps/api/plane/space/serializer/cycle.py validates incoming data and formats responses. API views in the views folder—particularly CycleViewSet—expose endpoints at /api/workspaces/{workspace}/projects/{project}/cycles/, handling standard CRUD operations plus specialized actions for archiving and analytics.

Service Layer: TypeScript HTTP Abstraction

The frontend communicates through a typed service layer that constructs URLs, manages HTTP verbs, and propagates errors consistently.

CycleService — Core CRUD and Analytics

Located at packages/services/src/cycle/cycle.service.ts, this class wraps all standard cycle operations:

// Fetching cycles with query parameters
async getWithParams(
  workspaceSlug: string,
  projectId: string,
  params?: Partial<CycleParams>
): Promise<ICycle[]> { ... }

// Creating a new sprint
async create(
  workspaceSlug: string,
  projectId: string,
  data: Partial<ICycle>
): Promise<ICycle> { ... }

// Real-time progress snapshots
async workspaceActiveCyclesProgress(
  workspaceSlug: string,
  cycleId: string
): Promise<IProgressSnapshot> { ... }

CycleOperationsService — Cross-Cycle Operations

Found in packages/services/src/cycle/cycle-operations.service.ts, this handles operations that span multiple cycles or involve user preferences:

  • addToFavorites / removeFromFavorites — user-specific cycle starring
  • transferIssues — moving issues between sprints without losing history

State Management: MobX Store with Computed Properties

The reactive heart of Plane's cycle management lives in apps/web/core/store/cycle.store.ts. This MobX store caches cycle data, derives filtered collections, and triggers UI updates automatically.

Key Store Properties

Property Purpose
cycleMap Keyed record of all loaded ICycle objects
activeCycleIdMap Tracks which cycle ID is currently active per project
archivedCycleIds Set of IDs for completed/archived sprints

Computed Getters for Sprint Views

The store automatically derives useful collections:

// Current active sprint for the selected project
get currentProjectActiveCycleId(): string | null;

// All completed cycles for retrospective analysis
get currentProjectCompletedCycleIds(): string[];

// Filtered view based on user search and filter selections
getFilteredCycleIds(projectId: string): string[];

How Cycles Flow Through the System

Creating a New Sprint

When a user initiates a new sprint, the data flows through all three layers:

  1. UI Component calls CycleStore.createCycle(workspaceSlug, projectId, data)
  2. Store delegates to CycleService.create, which POSTs to the Django endpoint
  3. Backend validates via CycleBaseSerializer and persists a new Cycle row
  4. Store normalizes the response into cycleMap, triggering reactive updates
import { useCycle } from "@/hooks/store/use-cycle";

function NewSprintForm({ workspaceSlug, projectId }: Props) {
  const { createCycle } = useCycle();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const data = {
      name: "Sprint 42",
      start_date: "2026-09-01T00:00:00Z",
      end_date: "2026-09-14T23:59:59Z",
    };
    await createCycle(workspaceSlug, projectId, data);
    // UI automatically updates because CycleStore caches the new cycle
  };

  return <form onSubmit={handleSubmit}>/* …inputs… */</form>;
}

Loading and Displaying the Active Sprint

The active cycle determination relies on the status field. A cycle with status === "current" (case-insensitive) is treated as the active sprint:

import { useEffect } from "react";
import { useCycle } from "@/hooks/store/use-cycle";

function ActiveSprint({ workspaceSlug, projectId }: Props) {
  const {
    fetchActiveCycle,
    fetchActiveCycleProgress,
    currentProjectActiveCycle,
    getIsPointsDataAvailable,
  } = useCycle();

  useEffect(() => {
    fetchActiveCycle(workspaceSlug, projectId);
    if (currentProjectActiveCycle?.id) {
      fetchActiveCycleProgress(workspaceSlug, projectId, currentProjectActiveCycle.id);
    }
  }, [workspaceSlug, projectId, currentProjectActiveCycle?.id]);

  if (!currentProjectActiveCycle) return <p>No active sprint.</p>;

  const hasPoints = getIsPointsDataAvailable(currentProjectActiveCycle.id);
  return (
    <div>
      <h2>{currentProjectActiveCycle.name}</h2>
      <p>{hasPoints ? "Points‑based burndown available" : "No point data yet"}</p>
    </div>
  );
}

User filtering combines store-side computation with utility helpers. The shouldFilterCycle function in packages/utils/src/work-item-filters/configs/filters/cycle.ts implements predicate logic for various filter dimensions—priority, state, assignees, date ranges. The store's getFilteredCycleIds method applies these predicates plus any text search query to produce the final display set.

Archives and Issue Transfer

Completed sprints move to archival through CycleArchiveService, which wraps /archive/ endpoints. The store updates archived_at timestamps and removes archived cycles from active collections. For replanning, CycleOperationsService.transferIssues migrates issues between cycles while preserving full history.

Analytics and Progress Tracking

Plane embeds real-time progress data directly into cycle records. The progress_snapshot JSON field stores burndown metrics, and estimate_distribution or distribution fields capture point-based breakdowns. The store's fetchActiveCycleAnalytics method populates these fields:

// From CycleStore
async fetchActiveCycleAnalytics(
  workspaceSlug: string,
  projectId: string,
  cycleId: string
): Promise<void> {
  const analytics = await this.cycleService.getCycleDetailsAnalytics(
    workspaceSlug,
    projectId,
    cycleId
  );
  // Merged into cycleMap[cycleId] for reactive access
}

Key Implementation Files for Reference

File Role
apps/api/plane/db/models/cycle.py Django ORM: Cycle, CycleIssue, CycleUserProperties models
apps/api/plane/space/serializer/cycle.py CycleBaseSerializer for API validation
packages/services/src/cycle/cycle.service.ts HTTP client: CRUD, list, analytics, validation
packages/services/src/cycle/cycle-operations.service.ts Favorites and issue-transfer operations
apps/web/core/store/cycle.store.ts MobX store: caching, computed properties, actions
packages/utils/src/work-item-filters/configs/filters/cycle.ts Filter predicate utilities
apps/web/core/components/cycles/ UI components: list views, selection, active sprint display

Summary

  • Plane's cycle management spans three architectural layers: Django models for persistence, TypeScript services for API abstraction, and MobX for reactive state.
  • Active sprint detection relies on the status field matching "current", with the store maintaining activeCycleIdMap for O(1) lookups.
  • Filtering and search combine store-level computation with reusable utilities in shouldFilterCycle, supporting priority, state, assignee, and date filters.
  • Progress analytics are embedded in cycle records via progress_snapshot and distribution fields, fetched on demand through dedicated service methods.
  • Archival and cross-cycle operations are handled by specialized services that maintain data integrity while updating reactive store state.

Frequently Asked Questions

How does Plane determine which sprint is currently active?

Plane checks the status field of each cycle for the value "current" (case-insensitive). The CycleStore computes currentProjectActiveCycleId by scanning the project's cycles, and maintains this in activeCycleIdMap for efficient access. When the active cycle progresses through its date range or is manually updated, reactive observers trigger UI re-renders automatically.

Can issues be moved between sprints without losing history?

Yes. The CycleOperationsService.transferIssues method in packages/services/src/cycle/cycle-operations.service.ts handles this migration. It updates the issue-to-cycle associations while preserving all activity history, comments, and state transitions. The store's cycleMap and issue caches update reactively to reflect the transfer.

How does Plane handle sprint progress analytics?

Progress data flows through CycleService.workspaceActiveCyclesProgress and CycleStore.fetchActiveCycleAnalytics, which populate the progress_snapshot JSON field and distribution metrics on cycle records. These snapshots include burndown data and point distributions, enabling real-time sprint dashboards without repeated heavy queries.

What happens when a sprint is archived?

Archival invokes CycleArchiveService to POST to the /archive/ endpoint. The Django backend sets archived_at, and the MobX store removes the cycle ID from active collections while adding it to archivedCycleIds. If the cycle was favorited, is_favorite is cleared and the store's favorite map updates accordingly.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →