Portfolio planning inside Jira — a real scheduling engine on the issues you already have.
Most roadmap views draw bars. They do not reschedule. When a task slips, somebody opens a spreadsheet, works out what moves, and edits a dozen issues by hand — and the plan is stale again by the next standup. LeanZero Management puts an actual scheduling engine behind the bars, so moving one task re-plans everything that depends on it.

Four rules, applied on every change, in topological order.
A task with three predecessors starts the working day after the LAST of them finishes — not the first. Merge points are where most roadmap tools quietly get the date wrong.
Weekends and holidays come out of the arithmetic, per plan. A five-day task starting Thursday finishes the following Wednesday, and a Sunday can never be a start date.
Mark a task as a buffer and it holds its due date while its duration shrinks. Upstream overruns are consumed by the buffer until it is exhausted — only then do your delivery dates move.
An epic's bar is the MIN start and MAX due of everything under it, recomputed on every change. You never maintain a parent date by hand.

Build a plan from a JQL query, a board, or whole projects — mixed together, across as many Jira projects as you need. Drag to reschedule, drag between bars to create a dependency, four zoom levels from day to quarter.
See the chain with no slack driving your finish date. Freeze a baseline and every bar carries a ghost of where it used to be, so drift is visible rather than reconstructed from memory.
A weighted health score, on-track and overdue counts, schedule-risk bands, buffer health per buffer, milestone tracking and baseline variance. Every row opens the Jira issue behind it.
Per-plan roles, drafts, live presence and a write lock. Two people editing the same plan cannot overwrite each other, and a failed write stays staged so it can be retried rather than lost.

You can drag bars around all afternoon without touching a single Jira issue. Changes stage locally; applying them is an explicit, reviewable step.

Only one apply runs at a time per plan, and a conflict check catches anything that changed in Jira while you were planning.
Optionally guard a plan's dates against edits made outside it, so an approved schedule stays the approved schedule.
Semantic checks a rule engine cannot make, on Atlassian's own hosted models. Advisory, never a gate, and off by default.

The complete reference: every scheduling rule with its arithmetic, every control on the timeline, every number on the dashboard and how it is computed, the permission model, the limits, and what to do when something does not behave the way you expected. 75 sections.
Every figure and formula here is taken from the app's own source and checked against it — the same content builds the documentation linked from the Atlassian Marketplace listing.
The app is a portfolio scheduler that copies a filtered set of Jira issues into its own store, lets you reschedule them as a dependency-linked plan, and writes the result back to Jira.
LeanZero Management is a Forge app for Jira Cloud (app id ari:cloud:ecosystem::app/087a8e18-d45a-4cb7-9d87-3e84101ac4f3, Node.js 22 runtime). It solves a problem Jira itself does not: Jira stores a start date and a due date on each issue, but it has no notion of a schedule that reacts. Move one issue and nothing downstream moves with it, parents do not re-derive their span from their children, and there is no place to see a whole delivery as one timeline with dependency arrows.
The app's answer is a plan. A plan is a saved definition of where issues come from (JQL queries, boards, whole projects), plus a working calendar, plus a copy of every matching issue held in the app's own storage. Once a plan is indexed you get a Gantt ("the timeline"), a table view and a dashboard over that copy. You reschedule inside the app, the cascade recalculates successors and parent roll-ups locally, and only when you choose to apply does anything get written back to Jira issues.
Indexing exists so the timeline can be rendered and recalculated without hammering Jira. It is also what makes plan-level features possible at all: dependency filtering (a "Blocks" link only becomes a plan dependency when both ends are in the plan), parent roll-up, and a stored snapshot (_original) of each issue's start date, due date, duration, buffer and links AS THEY WERE AT INDEX TIME, so the app can show you what you changed since that point.
Three user-facing surfaces (global page, issue panel, admin page) plus five background/platform modules, all served by one React bundle that routes itself from the Forge module context.
| Module | Manifest key | Title shown in Jira | What you get |
|---|---|---|---|
| jira:globalPage | ppm-dashboard | LeanZero Management | The main app. Opens on the plan list; from there you create a plan, open a plan's Gantt, Table and Dashboard views, its Schedule view and its Permissions view. Layout: basic. |
| jira:issuePanel | ppm-issue-panel | LeanZero Management Position | A panel on the Jira issue view showing which plans contain this issue and its dependency context. |
| jira:adminPage | ppm-admin-settings | LeanZero Management Settings | Instance-wide configuration. Five tabs: Calculation Engine, Field Mapping, Display, Plan Permissions, Maintenance. |
| Module | Key | Trigger / budget | Purpose |
|---|---|---|---|
| trigger | ppm-issue-guard | avi:jira:updated:issue | Runs plan protection on the change, then incrementally syncs that one issue into every plan that contains it. |
| scheduledTrigger | ppm-hourly-refresh | interval: hour | Fans out one refresh event per plan onto the index queue. |
| consumer | ppm-index-consumer | queue ppm-index-queue, timeoutSeconds: 900 | Runs the heavy index/refresh work off the resolver's ~25s limit. |
| llm | ppm-llm | model: claude | The Forge LLM the AI features use (JQL builder, plan review/assessment). AI is off until an admin enables it. |
| webtrigger | harness-test-state | gated by HARNESS_SECRET | Development harness endpoint only; 404 in production. |
Calculation Engine holds the dependency link type name (default "Blocks"), the engine limits (max cascade depth 10, max parent roll-up passes 5, max dependency-graph depth 15, max issues per calculation 150), the buffer impact prefix, and two indexing knobs (summary truncation 80, issues per storage shard 100). Field Mapping holds the field-id overrides. Display holds Gantt display preferences. Plan Permissions lists plans and their default access. Maintenance holds KVS clean-up plus the AI enable toggle, spend limits and a self-test. There is NO working-day calendar editor on the admin page — the wizard's Schedule step says "Add more calendars in Admin Settings", but nothing in the admin UI reads or writes cfg:working-days; only the saveWorkingDaysConfig resolver does.
All three modules point at the same resource (ppm-ui, built from static/ppm-ui/build) and the same resolver function. On mount, App.jsx calls view.getContext() and branches on ctx.extension.type: jira:issuePanel routes to the issue panel, jira:adminPage routes to admin settings, anything else falls through to the plan list. The issue panel and the admin page are rendered without the app shell (no sidebar/nav chrome), because they live inside Jira's own chrome.
It reads the issue key from context.extension.issue.key, calls listPlans, then calls getIssue against every visible plan in parallel (one round-trip for all plans rather than N sequential ones). Plans where the lookup returns an issue are listed as memberships, and the first match supplies the dependency mini-view. If no plan contains the issue you get "This issue is not part of any PPM plan."
Exactly what each of the five steps collects, which ones block Continue, and what the plan record looks like the moment it is created.
From the plan list, "+ New Plan" (or the create card) opens the wizard. The stepper across the top shows all five steps; you can click back to any COMPLETED step, but you cannot click forward — every step you have not finished is a disabled button. The footer's back button is labelled with the previous step's title (e.g. "← Sources"), not the word "Back"; the top-left "← Back" button leaves the wizard entirely.
| # | Title | Hint under the title | Collects | Blocks Continue? |
|---|---|---|---|---|
| 1 | Name | What are you planning? | Plan name | Yes — the trimmed name must be non-empty |
| 2 | Sources | Where do the issues come from? | One or more JQL / board / project sources | Yes — see the rule below |
| 3 | Schedule | Calendar & visibility | Working calendar + default access level | No |
| 4 | Milestones | Key target dates (optional) | Zero or more name + date pairs | No |
| 5 | Review | Confirm & index | Nothing — read-only summary | N/A (the button becomes "Create & Index") |
Heading "Name your plan". A single large input, autofocused, capped at 120 characters, placeholder "e.g. Q2 Release Plan". Pressing Enter with a non-empty name advances to step 2. Four suggestion chips fill the field on click: Q2 Release Plan, Platform Roadmap, Mobile Launch, Migration Wave 1. The name is not unique-checked and can be changed later via the updatePlan resolver.
The wizard starts with one empty JQL source labelled "Source 1". Each source card carries an editable label, a three-way type toggle (JQL / Board / Project), and a remove button that only appears when there is more than one source. "+ Add another source" appends a new JQL source labelled "Source N+1". The step's own header states the merge rule plainly: "Pull issues from a JQL query, a board, or a whole project. Mix as many as you like — they're merged into one plan."
sourcesValid =
sources.length > 0
AND every source has its identifier filled in (areSourcesValid, source-utils.js)
jql -> query.trim().length > 0
board -> boardId.toString().trim().length > 0
project -> projectKey.trim().length > 0
AND no JQL source is CONFIRMED invalid
jqlValidById[source.id] !== false
(null = empty or still validating -> allowed through)The calendar list is read from the working-days config (getWorkingDaysConfig, KVS key cfg:working-days). Each entry becomes a card showing its name and its working days spelled out (e.g. "Mon · Tue · Wed · Thu · Fri"), and the config's activeCalendar is preselected. The shipped defaults are standard — "Standard (Mon–Fri)", days [1,2,3,4,5] — and israel — "Israel (Sun–Thu)", days [0,1,2,3,4]. If the config cannot be read, the step falls back to a single hardcoded "Standard" option. The step's own sub-text invites you to "Add more calendars in Admin Settings", but no admin screen edits this config, so in practice you get the two shipped calendars unless saveWorkingDaysConfig is called directly.
| Value | Card title | Card description | Effect for a non-member |
|---|---|---|---|
| none | Private | Only you and members you invite | No access; the plan is filtered out of listPlans entirely |
| viewer | Everyone can view | Read-only for the whole site | Can view; cannot run the cascade, edit dependencies or apply |
| editor | Everyone can edit | Anyone can change the schedule | Can view and edit |
| admin | Everyone can administer | Full control for the whole site | Full control, including delete and permission management |
resolveRole checks, in order: Jira site/org admin (via mypermissions ADMINISTER) → plan owner (plan.createdBy matches a non-empty account id) → explicit entry in plan.members → legacy plan.editors array → the plan's defaultAccess (legacy plans with no defaultAccess fall back to 'viewer' if they have an editors array, otherwise 'none'). canView is any role other than 'none'; canEdit is admin, owner or editor; canDelete and canManagePermissions are admin or owner only.
Optional. Each row is a name (input capped at 80 characters) and a date picked with the app's own date picker. The empty state reads "No milestones yet — add a key date to anchor the plan, or skip this step." The wizard drops any row missing a name or a date before submitting, and the backend sanitizer then enforces the hard limits: at most 24 milestones per plan, names trimmed to 80 characters, dates must match ^\d{4}-\d{2}-\d{2}, ids truncated to 40 characters (a row with no id gets ms-<n>). Anything failing those rules is silently dropped.
The review page restates the plan name, the resolved calendar name (falling back to the literal string "Standard (Mon–Fri)" if the chosen key is not in the loaded list), the visibility title, then every source as a row of [type badge] [label] [detail] — where detail is the raw JQL, or "<board name> · #<id>", or "<project name> · KEY" — and finally the milestones that have both a name and a date. The primary button is "Create & Index" (it becomes a spinner plus "Creating…" while the flow runs).
| Field | Value on creation | Notes |
|---|---|---|
| status | 'created' | Shown as the badge "New" until indexing starts |
| version | 1 | Incremented on every save, every index that writes, and every incremental issue sync |
| issueCount / shardCount | 0 / 0 | Filled in by the first index |
| lastIndexedAt | null | Plan card shows "Indexed never" |
| calendarKey | the wizard's choice, or 'standard' | Stored but not read by the scheduler — see the callout above |
| holidayYears | [current year, current year + 1] | The wizard never sends this; plan holidays themselves live in the plan schedule document |
| includeParents | true unless explicitly sent as false | The wizard never sends it, so new plans get true; turning it on changes what a plan contains |
| protectionEnabled | true unless explicitly sent as false | The wizard never sends it either; enables the issue-updated guard that can revert an out-of-app date change |
| defaultAccess | the wizard's choice, or 'none' | 'none' means private |
| members | [] | Add people later from the plan's Permissions view |
| createdBy / createdByName | caller's accountId + display name | createdByName is best-effort; a failed /myself lookup leaves it empty |
| sources | re-identified as src-0, src-1, … | The wizard's client-side ids are replaced, and query/boardId/projectKey are nulled when unused |
JQL, board and project sources each fetch differently and validate differently; multiple sources merge by issue key with last-write-wins.
| Type | You supply | Jira call used | Paging |
|---|---|---|---|
| JQL | A query string | POST /rest/api/3/search/jql | 100 issues per page, followed via nextPageToken until Jira stops returning one |
| Board | A numeric board id | GET /rest/agile/1.0/board/{id}/issue | 100 requested per page; startAt advances by the number actually returned, an empty page stops the loop, and the loop also ends once the accumulated count reaches the reported total |
| Project | A project key | The project key is turned into JQL: project = "KEY" ORDER BY rank ASC, then the JQL path above | Same as JQL |
fetchIssuesFromProject accepts optional issueTypes and statusCategories filters, but both the indexer and the hourly refresh call it with an empty options object. A project source therefore always means every issue in that project, ordered by rank. If you want a subset, use a JQL source instead.
Every keystroke restarts a 600 ms debounce, then calls validateJql, which runs POST /rest/api/3/jql/parse?validation=strict — as the viewing user first, falling back to the app identity on 401/403. If Jira reports parse errors the field shows "✗ <first error>" in red and Continue is blocked. If it parses, the app additionally asks POST /rest/api/3/search/approximate-count and shows "✓ Valid · ~N issues" in green. The count is best-effort: if the count endpoint is unavailable the query still shows as valid, just without a number.
Board and project sources use a picker instead of free text. Typing searches live (250 ms debounce) — boards via GET /rest/agile/1.0/board?name=<q>&maxResults=25, projects via GET /rest/api/3/project/search?query=<q>&maxResults=20&orderBy=name. Arrow keys move, Enter commits the highlighted row (or the raw text when nothing is highlighted), Escape closes. If nothing matches you can still commit raw text via a "Use "…"" button: a board id must be all digits, a project key is upper-cased for you. Once committed, a confirmation line shows the name plus "ID 123" or the key, with a × to clear it.
Sources are fetched one after another in the order they appear on the plan, and every issue is put into a single map keyed by issue key. Duplicates are therefore deduplicated automatically, with the LAST source that returned an issue supplying the version that is kept. Because all three types produce raw Jira issue objects with the same requested field list, the versions are equivalent in practice.
Jira requests made through the shared client — the index-time source fetches, issue field updates, link create/delete, ranking, bulk fetch and the current-user lookup — go through a retry wrapper: 4 attempts, 2 s base delay with exponential backoff capped at 30 s, and a random 0.7–1.3 jitter factor so retries from parallel work do not synchronise. A 429 honours the Retry-After header when present (also capped at 30 s). Server errors (5xx) are retried; other 4xx responses throw immediately, because retrying a bad request just wastes the quota. Requests outside that client are NOT retried and fire exactly once: the wizard's validation and picker calls (JQL parse, approximate counts, board/project/filter lookups), the hourly change probe, field auto-setup, the admin permission check and the settable-fields (editmeta) probe each treat a failure as their own fallback instead.
After fetching your sources the indexer walks DOWN the parent tree to pull in every descendant, and optionally walks UP to pull in missing ancestors; anything whose parent is still outside the plan gets an orphan badge on the timeline.
The issues your sources return are rarely the whole tree. A board returns the issues on the board. A query like "project = X AND type = Epic" returns only the epics. Their stories and sub-tasks are missing, so the timeline would draw a parent bar with no rows under it and roll up nothing. Discovery closes that gap before anything is stored.
Since Jira's 2023 field unification, the parent field points at an issue's parent one level up at EVERY level: initiative ← epic, epic ← story/task/bug, story ← sub-task. So a query of parent IN (<keys>) returns exactly the direct children of those keys, whatever the levels involved. The indexer runs that as a breadth-first search: every round queries the keys found in the previous round, and the walk terminates naturally when a round finds nothing new. It never needs to know your hierarchy level names or how many levels you have. The same walk runs in the hourly refresh, or an hour later every child row would be stripped out again.
| Limit | Value | Why |
|---|---|---|
| Keys per parent IN clause | 80 (PARENT_BATCH) | JQL's practical value-list cap is around 1000, and the query is POSTed so there is no URL length limit; the batch is kept modest so each search call stays light and pages predictably |
| Maximum BFS rounds | 25 (MAX_ROUNDS) | Real hierarchies are a handful of levels (sub-task to epic is 3; Premium custom levels add a few more). 25 is far above anything Jira supports and exists only to guard against an unexpected cycle |
| Failure handling | Per batch | A batch Jira rejects is logged and skipped, so one bad key cannot abort the whole walk; a total discovery failure is warned and the index continues with just the source issues |
parent IN already returns sub-tasks, but as belt-and-braces for older or edge configurations the indexer also sweeps the subtasks[] array of every seed and every discovered issue, collects any key not already seen, and bulk-fetches them (POST /rest/api/3/issue/bulkfetch, 100 keys per chunk). It dedupes for free against what the walk already covered.
A source that matches a Story but not its Epic leaves that Story parentless, and the transformer nulls a parent key it cannot resolve — so the timeline never learns the item had a parent and draws it as a top-level row. That is the reported "why can I add a work item to the Gantt when its parent isn't there?". When a plan has includeParents === true, the indexer walks UP after the downward walk: parent is already in the fetched field list, so every issue carries fields.parent.key, and the app simply bulk-fetches the parents it is missing, then repeats for THEIR parents, up to the same ceiling of 25 rounds. A parent it cannot fetch is a warning that breaks the walk, never a failed index.
When an issue's parent exists in Jira but is not in this plan, the transformer nulls parentKey (the tree builder, the roll-up and the metrics all assume a parentKey resolves) but records the real key in orphanParentKey. The timeline renders that as a small monospaced pill on the row reading "↑ PARENT-KEY". Clicking it opens that parent in Jira. Hovering it says: "Parent <KEY> is not in this plan, so this item shows at the top level. Turn on "Include parents" in the plan's sources to pull it in." Without the badge an orphaned Story is indistinguishable from a genuine top-level item, and nothing explains why it is sitting at the root.
Each issue's level comes from issuetype.hierarchyLevel as Jira reports it. Only when Jira omits it does the app fall back to its own convention: sub-task = -1, everything else = 0. That fallback was previously wrong (it returned 1, labelling sub-tasks as epic-level); the corrected value matters because sub-tasks sit BELOW standard issues, not above.
Discovery is why a plan's issue count is usually higher than the count the wizard showed for your source. An epic-only JQL that previewed "~40 issues" can index several hundred once every story and sub-task under those epics is pulled in. If a plan is unexpectedly large, look at the shape of the tree under your seeds before blaming the query.
The exact field list requested from Jira, the configurable field ids, what the app auto-creates on first index, and how each raw value is parsed.
| Requested field | Configurable? | Default id | Becomes |
|---|---|---|---|
| summary | No | summary | summary, truncated to config.indexing.maxSummaryLength (default 80 characters) |
| issuetype | No | issuetype | type (the name) and hierarchyLevel |
| status | No | status | status (name) and statusCategory (the category key, or 'undefined') |
| parent | No | parent | parentKey when the parent is in the plan, otherwise orphanParentKey |
| assignee | No | assignee | assigneeName (display name) |
| Start date | Yes | customfield_10015 | startDate |
| Due date | Yes | duedate | dueDate |
| Duration | Yes | customfield_11581 | duration (working days, numeric) |
| Buffer | Yes | customfield_12399 | buffer ('Yes' or 'No') |
| Rank | Yes | customfield_10019 | rank — the Jira Software LexoRank string that drives timeline row order |
| issuelinks | No | issuelinks | predecessors / successors, filtered by link type and plan membership |
| subtasks | No | subtasks | children, filtered to keys present in the plan |
| priority, labels, resolution, reporter | No | (system) | priority, labels, resolution, reporter |
| created, updated | No | (system) | created / updated, truncated to the YYYY-MM-DD date part |
| customfield_10016 | No | customfield_10016 | storyPoints |
project = KEY. A plan whose sources are boards, or JQL using project IN (A, B) or a filter reference, contributes no project keys — so the PPM Duration / PPM Buffer fields land only on the "Default Screen" fallback, which will not be the edit screen for many projects. If duration or buffer values never appear on your issues, check the edit screen configuration for that project first. Admins can override every field id from the admin page's Field Mapping tab instead of relying on detection.| Value | Rule | Result when absent/odd |
|---|---|---|
| Duration | Number kept as-is; string run through parseFloat | null if missing or NaN |
| Buffer | An object with a .value (a select option) yields that value; a plain string yields itself | 'No' for anything falsy or unrecognised — buffer is never null |
| Start / Due date | Taken verbatim from the configured field | null |
| Rank | Taken verbatim (a LexoRank string) | null — and getAllIssues then sorts that issue by its key instead, so legacy rows cluster predictably |
| Summary | Sliced to the configured maximum length | empty string |
An issue link becomes a plan dependency only if BOTH conditions hold: its type name equals the configured dependency link type (default "Blocks"), and the issue at the other end is also in this plan. An inward link makes the other issue a predecessor; an outward link makes it a successor. This is why the same Jira link can be a dependency in one plan and invisible in another — and why the incremental updater re-transforms an issue separately for each plan that contains it, against that plan's own key set.
Every indexed issue also carries an _original block holding the Jira values of startDate, dueDate, duration, buffer, predecessors and successors at index time. That is what the timeline compares against to decide a bar is changed. It is NOT the source of the dashed "was here" outline — that comes from the separate user-set baseline snapshot (p:{planId}:bl), whose tooltip reads "Baseline — <KEY> was scheduled …".
Nothing is invented at index time: an issue with no start date and no due date is stored with startDate: null and dueDate: null, and the timeline draws no bar for it. Two things then happen in the UI. First, a PARENT still gets a span, because the chart re-derives every parent's dates at render time from its descendants — earliest start, latest due, recursively — and records which descendant supplied each end so the popup can name them ("Earliest: X · Latest: Y"). Note that a parent's own stored dates are ignored whenever it has children inside the plan. Second, hovering the empty timeline row of an undated issue shows a dashed ghost bar five columns wide, labelled with the hovered day and four calendar days later; clicking it schedules the issue for real — the start snaps forward to the next working day, the due date becomes the FIFTH working day counting that start day itself, and duration is set to 5. Hovering or clicking a non-working day does nothing.
The full pipeline from queue push to stored shards, including the guard that stops a zero-match re-index from wiping a populated plan.
Indexing is heavy — potentially thousands of issues fetched, a full hierarchy walk, a transform and a shard rewrite. A synchronous Custom UI resolver is capped at roughly 25 seconds, so large plans timed out. The indexPlan resolver therefore sets the plan to 'queued', pushes a {planId} event onto the ppm-index-queue and returns immediately; the consumer that picks it up has a 900-second budget. If the push itself fails (async events unavailable in that environment), the resolver falls back to running the pipeline inline so the plan still indexes — slower, and it may hit the 25-second limit on very large plans, but better than doing nothing.
plans:scope reverse index — the project keys of everything just indexed (an empty entry when the sources returned nothing) — so the issue-updated trigger can skip this plan for issues in projects it does not cover.If a re-index matches NOTHING but the plan previously had issues (issueCount or shardCount above zero), the app keeps the previous data. The plan stays 'indexed', its version is bumped, lastIndexedAt is left untouched, and it carries the message: "The last re-index matched 0 issues — the previous data was kept. Check the plan's sources." This exists because the old behaviour set shardCount to 0 and reported success — and since the issue reader trusts shardCount, the plan instantly rendered as empty, the whole schedule apparently gone, from nothing worse than a transient permission blip, a moved test issue or a JQL that stopped matching. The shards were not even deleted, just orphaned: the data was still there and unreachable. A plan that has genuinely never had issues still ends at 'indexed' with a count of 0, which is the correct empty state.
runIndexing catches its own errors, sets status 'error' with the error message as statusMessage, and emits an index:error event. It deliberately does not re-throw: a re-throw inside the queue consumer would make the platform retry the job repeatedly for the whole retention window, which is exactly wrong for a permanent failure such as a malformed JQL. Transient Jira errors (429 and 5xx) are already retried lower down, inside the Jira client.
| Knob | Value | Where it bites |
|---|---|---|
| Issues per search page | 100 | One POST per 100 issues for JQL and project sources |
| Board page size | 100 requested; Jira returns fewer | One GET per returned page |
| Parent keys per discovery query | 80 | Number of search calls per BFS round = ceil(frontier / 80) |
| Maximum BFS rounds | 25 | Hard ceiling; real hierarchies terminate in a handful |
| Bulk fetch chunk | 100 keys | Used for sub-task insurance and for the ancestor walk |
| Issues per storage shard | 100 | A hard constant (SHARD_SIZE in kvs-keys.js). The admin page exposes an "Issues per Storage Shard" setting, but nothing reads it — the value is inert |
| Parallel shard writes | 5 | Shards are written five at a time |
| Consumer budget | 900 s | Hard cap on one background index |
| Inline fallback budget | ~25 s | Only used when the queue push fails |
| Stuck-job threshold | 15 minutes | A plan sitting 'queued'/'indexing' with untouched meta beyond this is reported as errored |
| UI wait ceiling | 16 minutes, polled every 1.5 s | How long the wizard and the plan view wait before telling you it will finish in the background |
There is no fixed figure in the code, and the honest answer is that it scales with the number of issues, the shape of the hierarchy beneath them, and Jira's own responsiveness. Use the table above to estimate: a plan of a few hundred issues in a shallow tree is a handful of Jira calls and a handful of shard writes; a plan of several thousand issues across several hierarchy levels means dozens of search pages plus one BFS round per level, and is where the 900-second consumer budget starts to matter. If a job exceeds 15 minutes without touching the plan record it is treated as dead and offered for retry.
What each status value means, what the plan card tells you, and the gates that decide whether the hourly background refresh does any work at all.
| Stored value | Badge label | Badge description |
|---|---|---|
| created | New | "Plan created but not yet indexed. Click Re-index to load issues." |
| queued | (see gotcha) | No badge entry — the index job has been pushed to the queue but the consumer has not started it. |
| indexing | Indexing | "Fetching issues from your Jira sources. Please wait..." |
| indexed | Ready | "All issues are indexed and the plan is ready for editing." |
| calculating | Calculating | "Running chain calculation engine on the dependency graph." |
| writing | Writing | "Writing changes back to Jira tickets. Do not close the app." |
| error | Error | "An error occurred. Try re-indexing or check Admin Settings." statusMessage carries the real reason. |
The progress resolver returns the plan's status, statusMessage, issueCount and lastIndexedAt. It also self-heals: if the plan is still 'queued' or 'indexing' and its record has not been touched for more than 15 minutes, it reports status 'error' with "Indexing timed out — please retry." rather than leaving the UI wedged on a job whose consumer died. It only reports that status; the stored plan is left alone.
listPlans enriches each plan with its source count and types, the full sources array, lastIndexedAt, creator, members, defaultAccess, draft count, issue count, status, and YOUR resolved role — and then filters out every plan you cannot view. Each card shows three numbers (Issues, Sources, Drafts — the Drafts figure turns amber when there are any), one pill per source whose tooltip carries the raw JQL, "Board ID: <id>" or "Project: <KEY>", and a footer of "Updated <relative>" and "Indexed <relative>". Relative times read "just now" under a minute, then Nm / Nh / Nd ago, then an absolute locale date past seven days, and "never" when the timestamp is null. The card's ⋮ menu offers "Delete plan", which asks "Delete plan "<name>"? This cannot be undone."
The scheduled trigger runs once an hour. It does not refresh plans itself: it fans out one queue event per plan ({ planId, reason: 'scheduled' }), batched at 50 events per push request, so each plan's refresh runs in its own 900-second consumer invocation. (It used to push one request per plan with Promise.all, which throttled past the documented 500-events-per-minute limit, rejected on the first failure without cancelling the pushes that had already succeeded, and then re-refreshed every plan inline — refreshing hundreds of plans twice an hour.) If the fan-out fails entirely, it falls back to an inline serial refresh only when there are 25 plans or fewer; above that it skips the tick, because an inline pass could not finish anyway and would duplicate work already queued. The consumer also GCs drafts abandoned for over 24 hours while it is there.
| Check | Skips with reason | Rule |
|---|---|---|
| Recently indexed | recent | Skipped if lastIndexedAt is under 55 minutes old. This exists to stop a plan you just re-indexed manually being redone by the next tick — not to space out the schedule. |
| Busy | busy | Skipped if the plan's status is 'writing' or 'indexing'. |
| Gate 1: change probe | unchanged | One cheap Jira count — POST /rest/api/3/search/approximate-count with (all source scopes OR'd) AND updated > "<lastIndexedAt, UTC, rounded down to the minute>". A count of 0 means nothing moved. |
| Nothing fetched | empty | If every source came back with no issues, the refresh stops before transforming or writing, so it can never empty a populated plan. |
| Gate 2: content hash | unchanged | After fetching and transforming, the freshly built data is fingerprinted and compared with the stored fingerprint. Identical means nothing is written — not even a timestamp. |
The fingerprint is an FNV-1a hash over a sorted list of rows, one per issue, each carrying the issue key plus: summary, type, hierarchyLevel, status, statusCategory, parentKey, assigneeName, priority, resolution, storyPoints, startDate, dueDate, duration, buffer, rank, and the sorted predecessor, successor and children lists. Rows are sorted so Jira's return order is irrelevant, and the final value is prefixed with the row count. updated is deliberately excluded: Jira bumps it for changes the plan does not care about, such as a comment or a watcher, and including it would defeat the gate entirely — every commented-on issue would force a full shard rewrite.
updated timestamp.First, the issue-updated trigger: when any Jira issue changes, the app validates the change against plan protection (reverting it, with a comment, if the Iron Clad Rule was violated and the plan has protection enabled — in which case no sync follows), and otherwise re-fetches that one issue and updates it in every plan that contains it — transformed separately per plan so each plan's dependency filtering stays correct — then prunes stale lags, bumps that plan's version and notifies open views as an EXTERNAL change (notify, never silently overwrite someone's local edits). Second, the Re-index button in the plan toolbar, which runs the full pipeline on demand.
The toolbar's own help text states the contract: re-indexing — manual or the hourly background pass — pulls fresh Jira data for everything else and restores your unsaved edits on top from your autosaved draft. Because of that, Re-index asks for no confirmation. Use it when new issues should appear in the plan, when issues were modified directly in Jira, or when you simply want the plan to match Jira's current state.
A React custom UI talking to Forge resolvers, with plan data sharded across Atlassian's key-value store so a five-thousand-issue plan stays inside the platform's limits. Indexing runs asynchronously on a queue; an hourly refresh keeps plans in step with Jira and skips entirely when nothing relevant has changed.
The scheduling engine exists twice — once in the browser, where it has to be instant, and once on the server. A parity test suite settles the same plans through both and fails the build if they ever disagree, so what you preview is provably what gets applied.
Free while it is in beta. Install it from the Atlassian Marketplace and point it at a project you already run — it schedules the issues that are there, so there is nothing to set up first.