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