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 only service-layer additions are the read-only slim=1 (skip facets) and count=1 (skip facets + items) opt-in flags on /api/my-tasks introduced for the page itself and the dashboard "My Work" tile. 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).
  • applySavedView clamps any saved-view scope back to me for .own viewers, so picking a preset like "Overdue (everyone)" does not put the UI in a scope it can't actually fetch.
  • The Saved Views rail itself filters out presets that require all for .own viewers — only My open work, Due this week, and My blockers show up.
  • 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
TimelineGantt-style horizontal bars driven by startDate / dueDateSpotting overlaps, understanding sequence
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.

4. Filters

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

FilterTypeNotes
ProgrammeSingleSourced from /api/projects (each project carries its programmeId)
ProjectSingleFiltered to the active programme 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
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.

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 saved views — come from each project's projectStatus rows (with the isCompleted flag); projects without explicit rows fall back to the four-status default above.

5. Saved views

A left rail lists preset filter combinations with live counts that recompute as the underlying data changes. The shipped set (declared statically in saved-views-rail.tsx#SAVED_VIEWS):

  • My open work — Just me, hide done
  • Due this week — Just me, due within 7 days, hide done
  • My blockers — Just me, status = blocked
  • Overdue (everyone) — Everyone, due = overdue
  • In review — Everyone, status in ['qa', 'in_review', 'in_progress']
  • Unassigned — Everyone, no assignee

Activating a preset replaces all current filters and switches scope as the preset declares.

A New saved view button is rendered but is a placeholder for v1 — saved-view persistence (storing presets per user) is a follow-up. Today the rail is fixed and lives in component memory only; navigating away and back resets to defaults.

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. 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.
  • 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.

See also