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.

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.
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 doneToggleDrops every status flagged isCompleted (per-project), not a hard-coded done. On by default.

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 toggle, 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.

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.

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. Optimistic in the UI and rolled back on failure.
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.

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.

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.

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.

The marker moves on any change to the task's own row — status, progress, assignee, title, description, dates, estimates, priority, its parent or stage, and so on. What does not move it is an edit that touched only a task's custom-field values, or only its dependencies: those live in separate tables, so such a row still reads as unchanged and the sheet still overwrites it. If a colleague has been working only in custom fields, export again before importing rather than relying on the guard. 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