Skip to main content

Tasks page

Unified work surface at /projects/tasks. Replaces the previous /my-tasks page; the old URL 307-redirects to /tasks and preserves query params.

1. Purpose​

Tasks is the single screen a project manager or operator opens to find work, triage it, and act on it. The previous /my-tasks page was assignee-scoped and hard-coded to a list view; Tasks unifies "everything I might need to look at" — across scope, projects, and view types — so PMs do not have to bounce between dashboards.

The page is largely a presentational shell over the existing REST API: no new endpoints, and the service-layer additions are read-only opt-in query modes — the slim=1 (skip facets) and count=1 (skip facets + items) flags on /api/my-tasks (introduced for the page itself and the dashboard "My Work" tile), plus the options=1 slim cycle-options mode on /api/cycles and /api/projects/[id]/cycles backing the Cycle filter (PT-679, see §4). All persistence still goes through PATCH /api/projects/[projectId]/tasks/[taskId] and friends.

2. Scope toggle​

A pill in the header switches the visible task pool:

ScopeIncludesBacked by
EveryoneEvery task the viewer can read across all projects/api/my-tasks?mode=all (requires full tasks.read)
Just meTasks where assigneeId = currentUser.id/api/my-tasks?mode=mine
My teamTasks assigned to any other internal user (i.e. assigned, not the viewer, not a guest)/api/my-tasks?mode=all + client-side filter in tasks-screen.tsx

The pill is permission-locked: viewers without full tasks.read (i.e. tasks.read.own only) see only Just me because the other two scopes both hit mode=all and 403. Specifically:

  • The ScopePill is hidden for .own viewers (canSelectScope={hasFullTasksRead} in (app)/tasks/page.tsx).
  • The page applies a defensive apiScope = hasFullTasksRead && screenState.scope !== 'me' ? 'all' : 'me' clamp at the request boundary, so a stale ?scope=all or ?scope=team URL from a shared link silently falls back to mode=mine instead of 403'ing.

If a .own viewer does manage to hit a 403 from the API (e.g. tasks.read.own was just revoked mid-session), the page renders the forbidden access state — useTasksData.error is detected via (401) / (403) / forbid / permission substrings.

"My team" is currently approximated client-side as "anyone except me, anyone except guests" — there is no team-membership API yet. Once that lands, the filter will narrow to the viewer's actual team set.

3. Views​

Five view tabs share the same filtered task set. Switching views does not refetch.

ViewWhat it showsBest for
ListDense rows with inline-editable cells (priority, status, assignee, collaborators, due, title) and a sort menuTriage, bulk edits, manual reordering
BoardKanban columns grouped by status (or project / assignee / priority / stage) with drag-to-move and drag-to-reorderStatus-based standups, moving work across columns
TimelineA deadline schedule: each task placed by its real dueDate on a date axis anchored to a live "Today" line — a runway from today to the due date ending in a status-coloured marker (overdue red, completed green), grouped into project swimlanes, with un-dated tasks gathered in a "No due date" trayDeadline awareness, seeing what's slipping, scheduling coverage
CalendarMonth grid keyed off dueDateDeadline-heavy work; "what's due this week"
WorkloadPer-assignee rows summarising open task count, overdue count, and progressCapacity checks before assigning, spotting under/overload

Workload and Calendar do not show the Group-By menu (it does not apply); List is the only view with a Sort menu.

Status order in the List view (PT-1103). When the List groups or sorts by status, statuses run by their phase (ProjectStatus.category): in_progress, blocked, todo, done, cancelled. Active work comes first, then work not yet started, then closed work. Within a phase, each project's own sortOrder decides. The Board keeps the workflow column order.

Sorting by story points (PT-1256). The Sort menu's Story points option orders tasks by their estimate: ascending puts the smallest first, descending the largest. Tasks with no estimate always come last, in both directions, so a descending sort opens on the biggest estimated work rather than on unestimated tasks. A 0 estimate is a real estimate and sorts as zero. Equal estimates follow the manual order, reversed under descending, as with every other sort key. The sort is shareable as ?sort=points.asc or ?sort=points.desc.

Tree mode (List view, PT-268)​

The List view has an opt-in tree mode that nests subtasks under their parent task with a one-level indent. Toggle it with the Tree button next to the Group/Sort menus, or with ?tree=1 in the URL (both write the same state, so tree-mode URLs stay shareable).

  • Parents with visible subtasks grow an expand/collapse chevron; collapsing hides exactly that parent's children. Expand/collapse state persists per user per project (localStorage) and survives reloads.
  • Nesting composes with grouping: children attach to their parent within the same group, so a subtask whose parent sits in a different status bucket (or is filtered out) stays visible as a flat top-level row rather than disappearing.
  • Parents keep the chosen sort order and siblings keep their relative sorted order; children always render directly under their parent.
  • Drag-to-reorder (and the row menu's Move verbs) are disabled while tree mode is on — manual rank is a flat-list concept. Toggle tree mode off to reorder.

The default List view (tree mode off) is unchanged: subtasks appear as ordinary flat rows wherever the sort places them.

4. Filters​

The header filter bar applies to every view. Filters compose with the Scope toggle.

FilterTypeNotes
InitiativeSingleOptions sourced from /api/initiatives; each project from /api/projects carries its own initiativeId, used to map/filter task rows against the selected initiative
ProjectSingleFiltered to the active initiative when one is selected
StatusMultiPer-project status set, lowercased view-side (e.g. DB IN_PROGRESS → view in_progress). Defaults to pending / in_progress / qa / completed for projects without a custom status table. Selecting any completed status (e.g. done, completed, shipped) matches every task that is completed in its own project, both client-side and in /api/my-tasks?status= (PT-1103, PT-1118) — the same rule as Hide done, so a status name that is completed in one project but open in another only matches where it is completed. Other statuses, and issues, match by exact name. The response carries the completed status names of projects the caller can see as doneStatusNames, to recognise a done-like selection.
PriorityMultiurgent / high / medium / low / none (lowercased view-side; persisted on the task — see §7)
DueSingleoverdue / today / week (next 7 days) / month (next 30 days)
AssigneeMultiInternal users; _unassigned is a synthetic option
CycleMultiDelivery cycles (PT-679). URL key ?cycle=<uuid>,… (UUID-validated at decode) + last-used snapshot. Options come from the slim GET /api/cycles?options=1 read (id / name / project name). Applied server-side only (/api/my-tasks?cycleId=) — task rows carry no cycleId, so retained rows are hidden while a cycle-filtered feed settles. See §Cycle filter below.
SearchFree-textSubstring match across title, key, and project name
Hide doneURL onlyDrops every completed status of the task's own project, not a hard-coded done. Off by default, with no toggle: only a ?onlyOpen=1 link turns it on, for that visit only. It is never saved in your last-used view (PT-1139). While it is on, a Completed hidden ✕ chip names it, and clicking the chip turns off only this filter.

A Clear all chip appears once any filter is non-default.

Cycle filter (PT-679)​

The Cycle chip narrows the task pool to one or more delivery cycles. It differs from the other axes in three ways:

  • Server-only application. Task rows don't carry a cycleId, so the filter can't be evaluated client-side; the selection is forwarded to /api/my-tasks?cycleId=<uuid>,… (honoured in both mode=mine and mode=all) and the loader hides retained rows while a cycle-filtered feed is settling, so stale rows never flash under a new selection.
  • Slim options read. Chip options come from GET /api/cycles?options=1 (and the project-scoped GET /api/projects/[id]/cycles?options=1 on a single-project task view) — id / name / project name only, skipping the progress / burndown work the summary reader does.
  • Permission gating. The options fetch is gated on full tasks.read: cross-project cycle enumeration is intentionally full-read-only, so a tasks.read.own viewer gets no Cycle picker (no options to add a new filter). Deep-linked ?cycle= filtering is still honoured for own-scope viewers — the server applies the cycleId param regardless, and the active selection is unioned into the axis, so a shared cycle-filtered link still renders a clearable chip and keeps working.

Status mapping between API and view is a toLowerCase() on the DB value (data-mapping.ts#statusToView). Status options in the picker — and what counts as "done" for the Hide done filter, the toggle-done bulk action, and the queue predicates — come from each project's projectStatus rows (with the isCompleted flag); projects without explicit rows fall back to the four-status default above.

One definition of "completed" (PT-1118). Hide done, the overdue filter and queue, the done status filter, the Due grouping's Completed bucket, row and card done-state and the bulk done-toggle all use the same rule, on the client and in /api/my-tasks. A status is completed when its project flags it isCompleted. If the project flags no status, its last status by sort order is completed. A project with no configured statuses uses the legacy COMPLETED. The status category (done, cancelled, …) does not decide on its own. Status writes keep the two fields in step: saving a status in the done category marks it completed, and moving a status out of done without naming the flag reopens it. A done status sent with isCompleted: false is rejected. A completed status outside done, such as a completed cancelled "won't do", stays allowed. Statuses the settings forms saved in done before this rule were stored without the flag; the PT-1127 post-deploy repair (db:repair-done-category-statuses) flags them completed through the same path a status edit takes, so their tasks become completed, gain a completed_at, and close any open decision request. It only ever adds completion: in a project with no flagged status it also flags the status that is completed today (the last one by sort order), so nothing that reads as completed is reopened.

5. Queues​

A pill row between the title row and the filter bar narrows the task pool with one tap. Queues are composable predicates — they stack on top of whatever filters are active rather than replacing them — and the selection round-trips through the URL (?taskQueue=…), so a queue can be deep-linked and is remembered by the per-user last-used state.

QueueShows
AllNo queue narrowing (default)
My open tasksAssigned to the viewer and not completed
Due this weekDue inside the current ISO week (Mon–Sun)
To startNot-started status category, unassigned or assigned to the viewer — the human-facing analogue of the coordinator ready set
OverdueDue date in the past and not completed, whoever it is assigned to — the same predicate as the Due filter's overdue value

The canonical queue list lives in lib/task-queues.ts#TASK_QUEUE_IDS; the URL decode (via the isTaskQueueSelection guard, which also admits custom:<presetId> selections), the last-used snapshot schema, and the pill row all derive from it, so a queue added there is automatically valid in all three places.

Custom queues (PT-655)​

A "+" button after the built-in chips creates a user-defined custom queue: a modal exposes the client-evaluable filter axes (initiative, project, status, priority, due, stage, assignee, collaborator, labels — the same chips as the filter bar), optional search text, and a hide-completed toggle. The Cycle axis is deliberately excluded: it is applied server-side only (task rows carry no cycleId), so there is no client predicate for a custom queue to evaluate. Saved queues render as additional chips after the built-ins; while a custom chip is active, pencil/trash actions next to the "+" edit or delete it.

Custom queues follow the same composable-predicate model as the built-ins: the chip narrows the pool silently and ANDs with the live filter bar, which stays free for further narrowing — selecting a custom queue never overwrites the visible filters. Selections deep-link and persist through the same slot as built-in queues, as ?taskQueue=custom:<presetId>.

Persistence reuses the saved-filter-preset backend (/api/saved-filters, entityType: 'task') with a kind: 'task-queue' payload marker. Legacy presets from the pre-PT-645 saved-views rail share that namespace (per-user cap, unique names), so they resurface as custom queue chips and are migrated to the marked payload when edited. Saved axes are canonicalized on save and re-normalized against current options on apply (selecting every option of an exhaustive axis ≡ no filter), so a queue saved before a new status/project existed can't silently exclude it. Like the built-in queues, custom-queue predicates apply client-side over the loaded pool — on a truncated feed the truncation banner applies.

The earlier Saved views rail — and the "More filters" dropdown that later absorbed it — were removed in PT-645: the system presets duplicated the queues and the filter chips (with diverging week semantics). Custom queues (above) are their successor on Tasks, on the same /api/saved-filters backend that still powers the Issues page's presets.

6. Inline editing​

Every cell in the List view is a popover-on-click editor for that cell's field:

CellEditorPersists via
TitleInline text input on clickPATCH /api/projects/[projectId]/tasks/[taskId]
StatusStatus picker popoverSame endpoint; status mapped back to uppercase before sending
PriorityPriority picker popoverSame endpoint; priority mapped to DB value (URGENT / HIGH / MEDIUM / …)
AssigneeUser picker popover (internal users + Unassign)PATCH …/tasks/[taskId]
CollaboratorsMulti-select user popoverDiffed against current set, then POST / DELETE …/tasks/[taskId]/collaborators[/userId]
Due dateCalendar popover with quick offsets (Today / Tomorrow / +3 / +7 / +14)PATCH …/tasks/[taskId]
ProjectRead-only chip (no popover)Not inline-editable — the single-task PATCH validator does not accept projectId. Cross-project moves are only available via Bulk → Move (see §8).

Popovers are portalled to document.body (ReactDOM.createPortal) so they escape the row's overflow clipping. Click-outside or Escape closes the popover and discards uncommitted edits.

A status change that races a status edit is refused with a 409 (PT-939). A task's status is a name, not a link, so in a project with its own status definitions the database checks every write that changes a task's status (or moves it to another project) against them. This covers every path: inline edits, the drawer, bulk actions, drag and drop, imports, the API and the GitHub integration. Two refusals reach the caller as 409 Conflict, with details.reason naming which (the GitHub webhook instead reports the task as failed and answers 500, so GitHub redelivers the event):

  • PTD01 — "This project's statuses are being changed. Try again." Someone is deleting or renaming that status right now. Retry once their edit finishes.
  • PTD02 — "That status no longer exists in this project. Refresh and pick another." The status was deleted or renamed after the picker loaded.

Nothing is retried automatically, and nothing was written. Changing a status's label, colour, category, completion flag or order never refuses a task write. Projects with no configured statuses, and imports that create a project with its statuses, are not affected. Deleting a project's last status now resets the project to the default statuses straight away, which is what the status list already showed.

Edits from the preview panel and the drawer (PT-1168)​

Clicking a row in the List view opens the task in the side preview panel; other views, and narrow screens, open it in the popup drawer. Both mount the task detail, which saves each field itself. Every committed save is announced on a pt:task-updated window event (src/lib/entity-events.ts), and the list:

  • shows the edit on the row at once. It projects the fields a row displays: status, priority, assignee, reporter, due date, title, estimate, progress, labels, stage, issue type, resolution and acceptance criteria. It also stamps updatedAt, so a "Recently updated" sort moves the row.
  • then re-reads /api/my-tasks once. That read is the authority for everything a row cannot derive, such as the cycle, epic, parent or a container's status.

A previewed task that the edit moves out of the active filters leaves gracefully. For example, a status the Status filter does not include:

  • The row stays where it is, dimmed with a Hidden by filters badge, and the preview stays open.
  • The row is read-only while held: no inline edits, drag or selection, and it is removed from an existing selection and from select-all. It is edited from the panel beside it.
  • When you open another task or close the preview, the row fades out and leaves. With reduced motion, it leaves at once.
  • A row is only kept after an edit made in the preview. If you change the filters, queue, search, view or organisation yourself, the row hides and the preview closes as before.
  • If the filters still admit the row after an edit to fields the row shows, it is released straight away. After an edit to a field only the server filters on (a cycle, an initiative), the row is kept until the preview moves on.

Issues edited from the preview (PT-1170) and inline row edits (PT-1171) do not get this behaviour yet.

Deleting from the preview panel and the drawer (PT-1182)​

The task detail's delete action navigates away only on the task's own page, where it goes to the parent task, else to the project. Mounted in the preview panel or a drawer, it stays put and announces the delete on a pt:task-deleted window event (src/lib/entity-events.ts):

  • the tasks list drops the row at once, which also closes a preview showing it, including a row held as Hidden by filters, then re-reads /api/my-tasks once;
  • a drawer showing the deleted task closes itself, whichever page opened it;
  • a cycle page, and a task page's subtasks and epic children, re-read when that drawer closes, as they already did for any close; a task page's Dependencies drawer now does the same.

7. Priority and collaborators​

Priority and collaborators are first-class on tasks:

  • Priority is a column on projects.tasks (URGENT / HIGH / MEDIUM / LOW / NONE, default MEDIUM) and is persisted by the scalar task PATCH alongside title / status / assignee / due date.
  • Collaborators are stored in projects.task_collaborators as (taskId, userId) rows and managed via the dedicated …/tasks/[taskId]/collaborators and …/tasks/[taskId]/collaborators/[userId] endpoints. The data hook diffs the previous and next sets and issues the minimum number of POST / DELETE calls.

8. Bulk action bar​

Selecting one or more rows in List view (or cards in Board view) raises a floating action bar centred at the bottom of the viewport. Single-row selection is the same gesture as multi — there is no "select one" / "select many" split.

ActionEffect
Toggle doneMarks all selected tasks done; if all are already done, marks them todo
AssignAssign to a user, or Unassign
Due dateToday / Tomorrow / +3 / +7 / +14 / Clear
Priorityurgent / high / medium / low / none
MoveReassign selected tasks to another project — POST /api/tasks/bulk-move with { taskIds, targetProjectId } (the dedicated endpoint from PT-259, not the sparse bulk-update patch). The move reallocates each task's key from the target project's prefix/sequence and writes a TaskKeyRedirect so old URLs still resolve, reconciles status against the target's statuses (same name preserved, else the target's default), and rejects the batch (SUBTREE_NOT_INTACT) unless every selected task's parent and children are also selected. Cycle membership is scope-aware (PT-774): a project-scoped cycle membership is cleared and a REMOVED event written (the cycle no longer matches the task's project), while a non-archived initiative-scoped membership is preserved — it is defined by the task's epic chain, which a project move does not touch. Fail-closed: a cycle the move cannot positively resolve as non-archived and initiative-scoped (e.g. an archived one) is cleared like a project-scoped membership. A task whose membership is cleared and whose status is in progress in the target project is then re-attached to the target's active cycle, in the same move (PT-984). The move also re-checks each task's assignee and watchers against the target project (PT-1234). An assignee the target refuses under the task assignment rule, such as a collaborator on the source project only, is cleared. A watcher with no relationship to the target project or its organisation is removed. Both appear in the task's move audit entry and in the response's clearedAssignees and removedWatchers. A watch written while the move is in flight is checked against the task's project once the move has committed, so it cannot slip a watcher past the move (PT-1251). The tasks' attached files move with them: the database re-points each file at the target project in the same transaction, so it is listed in the target's files and no longer in the source's (PT-1308). Optimistic in the UI and rolled back on failure. Offered on both the org-wide /tasks page and a project's own Tasks page, where the destinations are the organisation's other projects (PT-1037). A project page withholds them until the active organisation is resolved, and for the rest of the page's life once it changes — a switch made in another tab included; reload to restore them.
DeleteDestructive — issues DELETE /api/projects/[projectId]/tasks/[taskId] (or /api/issues/[id] for issues) per row, sequentially. Optimistic: rows drop immediately and are restored if any DELETE fails.

Esc clears the selection and dismisses the bar.

Which tasks a bulk update reaches (PT-1053). Status, Assign, Due date, Priority and Add to cycle go through POST /api/tasks/bulk-update (Toggle done and Delete issue one request per row instead), which needs tasks.update.any; the Add to cycle picker resolves the selection the same way. A caller reaches every non-archived project in the organisation only with all of: an active internal (non-guest) membership, and — from organisation-wide role assignments — org-wide projects.read (projects.read, projects.read.any, projects.* or *; projects.read.* does not count), full tasks.read, and tasks.update.any. Everyone else — a guest, a caller with only .own read grants or no project read at all, one whose read or update grants come from a project- or programme-scoped role, or one whose membership cannot be resolved as active — reaches only projects they created or actively collaborate on. This is deliberately stricter than the list in places, so a task can be visible on the page but outside a bulk update: for example a caller with full projects.read but only tasks.read.own sees their own tasks in every project. The batch is all-or-nothing, so one unreachable task rejects the whole selection.

What a refused batch says (PT-1055). A rejection the user can act on is shown as the server wrote it, with the blamed tasks named by key (up to three, then "and N more"). That covers a status that some selected project does not have (STATUS_NOT_CONFIGURED), selected tasks that cannot be updated in bulk (TASKS_NOT_FOUND), an assignee who cannot be assigned work in some selected project (ASSIGNEE_NOT_ASSIGNABLE), and the cycle rejections (see Cycles). TASKS_NOT_FOUND covers tasks that were deleted and tasks outside the reach described above, and deliberately does not say which, so it cannot reveal whether an id exists. Each rejection carries details.reason and ineligibleTaskIds in the error envelope. Server errors and network failures still show "Bulk action failed. Please retry.". After a TASKS_NOT_FOUND, the tasks it blamed leave the selection and the toast says so; when any selected tasks remain, running the action again applies it to them (PT-1074). A task deleted from the preview panel or the task drawer leaves the selection too. A list refresh that no longer shows a selected task does not deselect it, because it may be on a later page.

Watcher notifications after a bulk update (PT-1359). The notifications a bulk update owes run once its batch has committed, so a batch that is rolled back notifies nobody. They run four at a time, and each task's watchers are queued one at a time, so a full 100-task batch does not use up the database connection pool. Before PT-1359 they all started at once, and a wide batch could silently drop many of them. The request now waits for them before it answers, which makes a wide bulk update noticeably slower to respond. The wait is bounded: past 24.5 s from the start of the update it answers anyway. Notifications not yet queued by then are best-effort and may be lost if the server is suspended after it answers. The linked GitHub issues' status sync is started the same way but is not waited for.

9. Drag and drop​

Two drag interactions are supported:

  • Reorder within manual sort (List view, sort key manual) — drop targets render a thin accent line indicating insertion position. The drop calls the data hook's reorderTask({ id, beforeId | afterId }). Drag-to-reorder is disabled when the active sort is anything other than manual, and also whenever tree mode is on regardless of sort (see §3 — manual rank is a flat-list concept).
  • Column-to-column move (Board view) — dropping a card on another column updates the grouped field (status when grouped by status; project when grouped by project; etc.) via moveTask. Empty columns highlight as drop zones.

The FLIP hook (useFlipChildren) animates row position changes after the optimistic update so users see where their card landed.

10. URL and permission gate​

  • Path: /tasks in dev / preview, /projects/tasks in production (the /projects basePath is added by the multi-zone rewrite — see Project Tracker module §9).
  • Layout: Uses the standard (app) layout. Sidebar's "Tasks" entry points here.
  • Permission: Same gate as the old /my-tasks page — tasks.read or tasks.read.own, AND projects.read or projects.read.own. Users without either see the layout's "no access" state.

The old /my-tasks URL is preserved as a redirect in apps/project-tracker/src/app/(app)/my-tasks/page.tsx so existing bookmarks, agent links, and notification deep-links keep working.

11. Excel import & update (PT-737, PT-744, PT-745, PT-746, PT-750, PT-751)​

Tasks can be imported from — and exported to — Excel (.xlsx). Import is an explicit choice, not a guess from the spreadsheet contents:

  • Create new project — a new project is created from the file. Leave the ID column blank so every row is imported as a new task. A workbook that still carries IDs in create mode is rejected up front (those keys may collide with existing tasks or preserve misleading source keys), with a prompt to clear the column. To keep parent and dependency links between the new rows without carrying old IDs, give every row that another row references a sheet-local Import Ref (any unique, comma-free text) and name it from that row's Parent ID / Dependencies; those resolve within the sheet and stay in create mode (PT-744) — an Import Ref never counts as an ID, so it cannot flip the workbook into update mode. The downloaded template ships the column and worked example rows for both shapes (PT-746). An export does not carry the column, and its Parent ID / Dependencies hold Task Keys, so converting one whose rows have relationships is two steps: add an Import Ref column, copy each row's old ID into it, then clear the ID column — the keys already in the file then resolve as local refs. Leaving old exported Task Keys in Parent ID / Dependencies while the ID column is cleared is rejected (a flat export has nothing to preserve and needs no refs). Forward references are fine (a parent or dependency may appear after the row that names it).
  • Update existing project — an existing project is updated from an edited export. Keep the ID column so each row maps back to its task; rows whose ID is cleared are added as new tasks. In the global dialog (no target project), an update with no IDs at all is rejected — there is nothing to resolve; the per-project action below is bound to its project and so can accept an all-new set of rows.

Two entry points:

  • Global — the Import button on the Projects list opens the dialog with the create-vs-update segmented control (create is the default).
  • Per project — an Import / Update from Excel action in the project header runs the export → edit → re-import loop against that project. It is bound to the project's id server-side, so a workbook exported from a different project cannot modify it. The button is shown when you have either capability the import can use — tasks.update.any or tasks.create (not projects.update); at execution a mixed workbook needs both (update for ID rows, create for cleared-ID rows), and the server enforces each per row. When you are missing both, one error names both permissions, each with the sheet rows that need it.

Getting the file to edit is the Export to Excel action (in the project header's Export menu). Like the CSV export, it is read-level — gated on projects.read or projects.read.own against the project's own org, so a read-only stakeholder (and a verified cross-org collaborator, who is granted a synthesised projects.read.own for the project even without org membership) can obtain the file to import. Only the JSON backup in that menu stays gated on projects.update. The import/update step still enforces the task capabilities above, so a viewer can export but not necessarily re-import. The import endpoint is POST /api/projects/import/excel; it returns 201 when a new project is created and 200 when an existing one is updated. See estimatePoints in cycles for how sizing columns round-trip.

Every import is audited (PT-751). An update can overwrite many tasks at once, so the importer records what it changed. Each created and updated task row gets an audit entry holding that task's before → after values for the columns the import wrote, attributed to the person who ran the import; a single batch entry on the project records the mode and the created/updated counts and ties the per-task entries together (they share one correlation id, which the projects.project.imported event carries too). The audit rows are written in the same transaction as the import: if the trail cannot be written, the import does not commit (self-hosted operators can relax this with the platform's lenient-audit override). So a re-import of a stale export is reconstructable afterwards — the entries are readable through the audit surface, which filters by resource (a single task, or the project for the batch entry), by actor, and by date. This covers the task's own columns. An update also replaces that task's custom field values and dependencies wholesale; those are not compared value-by-value yet, so the entry flags them as replaced — a row showing no column changes still tells you they were rewritten rather than implying nothing happened. Gate criteria are rebuilt per gate rather than per task, and carry no per-task marker.

A row with a Parent ID is a sub-task (PT-1034). PT's rule is that a task with a parent is a SUBTASK (see the type/parent contract), and the import follows it. The workbook has no issue-type column, so a row whose Parent ID is filled in is imported as a sub-task — whether the row creates the task or updates it, and whatever type the task had before. Two update rows are refused instead, with an error on the Parent ID column, because the same change is refused everywhere else in PT:

  • a Parent ID on a task that is an epic — an epic cannot be a sub-task; and
  • a blank Parent ID on a task that is currently a sub-task — detaching one means choosing what it becomes, which the workbook cannot say. Keep its Parent ID, or detach it in the app. This includes a workbook with no Parent ID column at all: its sub-task rows are refused rather than silently detached, as they used to be.

Every such row is listed at once, before anything is written. A row skipped as stale is not refused, since it is not written. If a parent task is moved to another project while the import runs, its sub-task rows are refused with a message that says so.

How cells are read. Formatting never changes what a cell imports, so you can style the workbook freely — a bold or mixed-formatted cell imports its text, and a hyperlinked cell imports its display text (the link target is not stored). A formula imports its last computed value as saved by Excel, not the formula itself. A cell with no usable value — a formula whose saved result is blank (e.g. =IF(A1="","",A1)), or an error such as #DIV/0! — imports as blank, exactly as an empty cell would; in a required column like Title that surfaces as the normal "required" row error. One caveat on formulas: Excel saves the computed value alongside the formula, so a workbook generated by a tool that writes formulas without computing them has nothing to read, and those cells import as blank.

Create-mode references resolve all-or-nothing (PT-745). A create-mode Parent ID or Dependencies value that names no Import Ref in the sheet is rejected up front, with a row-level error naming the offending row — so is a duplicate or comma-containing Import Ref, either of which would make a reference ambiguous. Should a reference somehow survive validation and still fail to resolve while the rows are being written, the whole import is aborted and rolled back rather than committed with that link quietly missing. So for a create-mode import the promise is: if it succeeds, every parent link and dependency edge you wrote in the sheet exists in the new project — a plan whose hierarchy or dependency graph is thinner than the file you uploaded, with nothing in the result to say so, is not a possible outcome.

Update mode has one deliberate exception. If someone moves a task out of the target project while your import is running — after its ID was resolved but before its row is written — that row is skipped rather than reaching across into its new project. It is not counted in the updated total, and any dependency naming it is skipped too; the rest of the import still commits. This is a concurrency safeguard, not a silent drop of a reference the sheet got wrong: a dependency naming a task absent from the sheet is rejected during validation in both modes.

Blank Progress on update preserves the stored value (PT-748). When updating an existing project, a blank Progress cell leaves the task's stored progress unchanged rather than resetting it to 0 — so you can edit other columns without having to re-enter progress on every row. A filled cell still overwrites (an explicit 0 sets it to 0). In create mode a blank Progress defaults to 0, as there is no prior value to keep.

The export carries a Last Updated version marker (PT-750). Every exported row ends its built-in columns with Last Updated: that task's own last-modified time as at the moment of export, written as an ISO 8601 UTC timestamp (2026-07-20T10:11:12.345Z). It is the task's timestamp, not the export's, so on a project nobody has touched for a month it reads a month old. On an update-mode re-import it protects a colleague's newer edits: a row whose task has been changed in the app since the export is skipped in full, its columns, custom-field values and dependencies all left as they are, and reported back in the import result rather than being overwritten from the older sheet.

Where the skipped rows are reported (PT-858). The import dialog lists them in an amber "N row(s) skipped as out of date" panel that names every skipped row by Task Key, sheet row number and title — nothing is truncated, because re-applying a skipped edit means finding that exact row again. The skips also count towards the import's warnings, so a partially applied file never reads as an unqualified success. Over the API the same list comes back on every successful import at data.summary.staleSkipped, mirrored at data.staleSkipped, and is an empty list when nothing was skipped.

Estimate changes on cycle members follow the cycle rules (PT-975). When an update-mode import changes the Estimated Points of a task that is in a cycle, the change is recorded in the cycle's membership history, exactly as an edit in the app would be, so the burndown accounts for it. A raise is also measured against the cycle's points target. An upload has no one to type an override reason, so a raise that takes the cycle past its target is applied, not refused: the breach is recorded with its overage under a fixed "spreadsheet import" reason and written to the audit log. Every task the import applied that way is returned on the successful import at data.summary.capacityOverages, mirrored at data.capacityOverages, with its Task Key, sheet row, title, cycle and the points past the target. The list is empty when nothing breached. The import dialog does not show it yet.

The cycle and the overage are named only to an importer who could see the whole cycle anyway: full tasks.read, read access to every project the cycle's tasks live in, and to the cycle's owner (its project, initiative or project set). A cycle can span projects the importer does not read, and the overage is a total over all of them, so anyone else gets the row's Task Key, sheet row and title with restrictedVisibility: true instead. This is the same rule a capacity refusal follows. The breach is recorded in full either way.

The marker moves on any change to the task — status, progress, assignee, title, description, dates, estimates, priority, its parent or stage, and so on, and also an edit of only its custom-field values or only its dependencies (PT-833). A dependency belongs to the task that waits: adding, removing or replacing what a task depends on moves that task's marker, not the prerequisite's. Saving a value or a dependency list that did not change does not move it, and neither does deleting a custom field definition or a prerequisite task, since neither is an edit of the task that held them. The rest of the sheet still applies, so one skipped row never blocks the import. A skipped task also stays usable as another row's parent or dependency, so nothing else in the sheet is re-parented or unlinked because of it.

The comparison also resolves to the millisecond: two writes landing inside the same millisecond are indistinguishable to the guard, so an edit made in the same millisecond as the row's previous change can still be overwritten. Hitting that window takes machine-speed writes bracketing the export, so it is out of reach of human editing — but the promise is "no edit is lost in practice", not a lock.

There are three exceptions, and each fails the import rather than skipping quietly. Because a skipped row keeps its live parent and dependencies, the sheet's other rows can end up committing a structure Constellation does not allow:

  • A sub-subtask — most often a row added under a task a colleague has meanwhile moved under another task. Rather than write a nesting that is rejected everywhere else in the app, the import stops, names the row at fault, and commits nothing at all. The same applies when a row would become a subtask while it still has subtasks of its own.
  • A circular dependency — a sheet row's dependency that, combined with the dependencies a skipped row keeps, would make a chain of tasks depend on each other in a loop. Only a cycle the import itself would close is rejected; one that already existed in the data does not stop an import that leaves it untouched.
  • A new Progress value on a task that keeps a subtask it was meant to lose — the sheet moved the subtask out from under it (which would have made its progress editable), but the subtask's row was skipped and stays put, so the task remains one whose progress is calculated from its subtasks. Only an actual change is rejected: a row whose Progress still matches the task's current stored value does not stop the import (for a task nobody touched since export, that is the value it was exported with), and neither does a stale subtask whose parent the sheet left unchanged — nor a task the sheet still lists another subtask under: there the sheet set the value knowing the task keeps subtasks, which the import has always accepted and this safeguard leaves alone.

In all three cases the import stops, names the row at fault, and the fix is the one the message gives: export the project again and re-apply your changes to the fresh sheet.

A parent moved to a different project since the export is never linked across the boundary. Depending on where that parent sits in the sheet, that either stops the import the same way or lets the row import without a parent — and in the second case nothing is reported, so re-exporting is the way to see where the row ended up.

Leave the column exactly as exported: it is what makes that protection work. A sheet without the column at all — the downloaded template does not carry it, and neither does a hand-authored file — stays valid and imports without this protection, as does one whose cell you have cleared. That opts a row out of the newer-edit check only; the structural rules above — the one-level subtask limit and the cross-project parent — still apply to every import, marker or no marker. What is not ignored is a value that cannot be read: an edited marker that has lost its seconds or milliseconds, an impossible date, or an unreadable cell (#REF!, a formula with no saved result) is reported as a row error telling you to restore the exported format or clear the cell, rather than being silently dropped. Last Updated is also reserved: a custom field with that label is rejected, exactly as for the other built-in column names.

Export again before you import the same file a second time. The first import updates the rows it writes, so their markers in the file you just imported are now out of date and a second run reports those keyed rows as skipped. For those rows nothing is lost — the values had already been applied — but the result reads as a wall of skips rather than "nothing left to do".

Any row you added without an ID is a different matter: the guard does not apply to it at all, because a blank ID means "create". Your saved file still has no ID and no marker for it, so importing that file again creates the task a second time. The marker cannot prevent this; only re-exporting can, since the fresh export carries the key the first import allocated. Re-export after every import and both problems go away — the skipped-row report goes back to meaning what it says (someone else changed the task), and added rows resolve to the tasks they created.

Two structural checks the marker makes load-bearing (PT-750). Both were previously silent last-writer-wins, and both can lose data, so the workbook is now rejected up front instead:

  • A repeated column name is rejected before any row is parsed, naming the column. Reads of a duplicated header resolve to the rightmost copy, so the extra column silently shadows the one you meant — which surfaces as a wall of unrelated per-row errors, or as no error at all.
  • Two update-mode rows carrying the same ID are rejected, reported against the second row. Both target the same task, so the sheet does not say what that task should end up as.

See also​