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/programmes,/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 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:
| 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). applySavedViewclamps any saved-view scope back tomefor.ownviewers, 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
allfor.ownviewers — onlyMy open work,Due this week, andMy blockersshow up. - 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 | Gantt-style horizontal bars driven by startDate / dueDate | Spotting overlaps, understanding sequence |
| 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.
4. Filters
The header filter bar applies to every view. Filters compose with the Scope toggle.
| Filter | Type | Notes |
|---|---|---|
| Programme | Single | Sourced from /api/projects (each project carries its programmeId) |
| Project | Single | Filtered to the active programme 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. |
| 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 |
| Search | Free-text | Substring match across title, key, and project name |
| Hide done | Toggle | Drops 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:
| 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.
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. Optimistic in the UI and rolled back on failure. |
| 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.
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. - 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.
See also
- Project Tracker module — entities, layers, REST API entry points.
- Implementation Plan — broader feature roadmap including upcoming task-model additions.
- Project Tracker API — Tasks — endpoints used by the data hook.