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.
- Source:
apps/project-tracker/src/app/(app)/tasks/page.tsx - Components:
apps/project-tracker/src/components/tasks-page/ - Data hook:
useTasksData— fetches/api/my-tasks?slim=1(the page builds its own facets soslimsaves a DB round-trip on the route),/api/projects,/api/initiatives,/api/people?scope=active-projects, and per-project/api/projects/[id]/statusesfor inline status pickers. MapsWorkItem→Task.
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:
| Scope | Includes | Backed by |
|---|---|---|
| Everyone | Every task the viewer can read across all projects | /api/my-tasks?mode=all (requires full tasks.read) |
| Just me | Tasks where assigneeId = currentUser.id | /api/my-tasks?mode=mine |
| My team | Tasks 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
ScopePillis hidden for.ownviewers (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=allor?scope=teamURL from a shared link silently falls back tomode=mineinstead 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.
| View | What it shows | Best for |
|---|---|---|
| List | Dense rows with inline-editable cells (priority, status, assignee, collaborators, due, title) and a sort menu | Triage, bulk edits, manual reordering |
| Board | Kanban columns grouped by status (or project / assignee / priority / stage) with drag-to-move and drag-to-reorder | Status-based standups, moving work across columns |
| Timeline | A 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" tray | Deadline awareness, seeing what's slipping, scheduling coverage |
| Calendar | Month grid keyed off dueDate | Deadline-heavy work; "what's due this week" |
| Workload | Per-assignee rows summarising open task count, overdue count, and progress | Capacity 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.
| Filter | Type | Notes |
|---|---|---|
| Initiative | Single | Options sourced from /api/initiatives; each project from /api/projects carries its own initiativeId, used to map/filter task rows against the selected initiative |
| Project | Single | Filtered to the active initiative when one is selected |
| Status | Multi | Per-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. |
| Priority | Multi | urgent / high / medium / low / none (lowercased view-side; persisted on the task — see §7) |
| Due | Single | overdue / today / week (next 7 days) / month (next 30 days) |
| Assignee | Multi | Internal users; _unassigned is a synthetic option |
| Cycle | Multi | Delivery 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. |
| Search | Free-text | Substring match across title, key, and project name |
| Hide done | URL only | Drops 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 bothmode=mineandmode=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-scopedGET /api/projects/[id]/cycles?options=1on 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 atasks.read.ownviewer 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 thecycleIdparam 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.
| Queue | Shows |
|---|---|
| All | No queue narrowing (default) |
| My open tasks | Assigned to the viewer and not completed |
| Due this week | Due inside the current ISO week (Mon–Sun) |
| To start | Not-started status category, unassigned or assigned to the viewer — the human-facing analogue of the coordinator ready set |
| Overdue | Due 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:
| Cell | Editor | Persists via |
|---|---|---|
| Title | Inline text input on click | PATCH /api/projects/[projectId]/tasks/[taskId] |
| Status | Status picker popover | Same endpoint; status mapped back to uppercase before sending |
| Priority | Priority picker popover | Same endpoint; priority mapped to DB value (URGENT / HIGH / MEDIUM / …) |
| Assignee | User picker popover (internal users + Unassign) | PATCH …/tasks/[taskId] |
| Collaborators | Multi-select user popover | Diffed against current set, then POST / DELETE …/tasks/[taskId]/collaborators[/userId] |
| Due date | Calendar popover with quick offsets (Today / Tomorrow / +3 / +7 / +14) | PATCH …/tasks/[taskId] |
| Project | Read-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-tasksonce. 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-tasksonce; - 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, defaultMEDIUM) and is persisted by the scalar task PATCH alongside title / status / assignee / due date. - Collaborators are stored in
projects.task_collaboratorsas(taskId, userId)rows and managed via the dedicated…/tasks/[taskId]/collaboratorsand…/tasks/[taskId]/collaborators/[userId]endpoints. The data hook diffs the previous and next sets and issues the minimum number ofPOST/DELETEcalls.
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.
| Action | Effect |
|---|---|
| Toggle done | Marks all selected tasks done; if all are already done, marks them todo |
| Assign | Assign to a user, or Unassign |
| Due date | Today / Tomorrow / +3 / +7 / +14 / Clear |
| Priority | urgent / high / medium / low / none |
| Move | Reassign 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. |
| Delete | Destructive — 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'sreorderTask({ id, beforeId | afterId }). Drag-to-reorder is disabled when the active sort is anything other thanmanual, 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:
/tasksin dev / preview,/projects/tasksin production (the/projectsbasePath 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-taskspage —tasks.readortasks.read.own, ANDprojects.readorprojects.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 Refnever 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 itsParent ID/Dependencieshold Task Keys, so converting one whose rows have relationships is two steps: add anImport Refcolumn, 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 inParent ID/Dependencieswhile 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.anyortasks.create(notprojects.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
- Project Tracker module — entities, layers, REST API entry points.
- Implementation Plan (archived) — historical feature roadmap (dated 2026-03-30); superseded by shipped state.
- Milestones — payment-linked milestone tracking.
- Project Tracker API — Tasks — endpoints used by the data hook.