Comment threads on task cards
Create a thorough implementation plan for adding threaded comments to task cards. Include mockups, the data flow from client to persistence, the key code I'll need to write, and a risk table. Make it easy to skim on a phone - I'm going to pass this to the implementer as-is.
Milestones
Ship in four slices, each independently reviewable and each behind the flag. Nothing is user-visible until slice 4.
Schema & API contract
New comments and comment_reads tables, migrations, and the tRPC router stubs. No UI. Contract reviewed before anything else lands.
Thread component & composer
Static <CommentThread> rendered from fixtures. Optimistic insert on submit, rollback on failure, one level of nesting only.
Realtime fan-out & unread state
Subscribe the open card to its comment channel. Track per-user read cursors so the sidebar can show an unread count without a second query.
Notifications, flag ramp, docs
Mention detection into notification rows, email digest fallback, ramp task_comments_v1 to internal, then 10% to 100% over three days.
Data flow
Optimistic write path on the left, fan-out on the right. The read cursor update is fire-and-forget, so the thread render never waits on it.
Solid = request/response path. Dashed brand line = realtime fan-out. The composer never waits on the dashed path.
Mockups
Not pixel-final - just enough that the reviewer and implementer agree on nesting depth, composer placement, and what the sidebar digest looks like.
projects.count > 0 and fall back to the minimal variant.Key code
The two pieces most likely to be done wrong: the migration for soft deletes and read cursors, and the optimistic mutation for temp-id reconciliation.
create table comments ( id uuid primary key default gen_random_uuid(), task_id uuid not null references tasks(id), parent_id uuid references comments(id), -- one level only, -- enforced in API author_id uuid not null references users(id), body text not null, created_at timestamptz not null default now(), deleted_at timestamptz -- soft delete ); create table comment_reads ( task_id uuid not null references tasks(id), user_id uuid not null references users(id), read_up_to timestamptz not null, primary key (task_id, user_id) ); create index comments_task_created on comments (task_id, created_at);
export function useAddComment(taskId: string) { const qc = useQueryClient(); return trpc.comments.create.useMutation({ onMutate: async (input) => { const temp = { ...input, id: `temp-${nanoid()}`, createdAt: new Date(), pending: true }; qc.setQueryData(key(taskId), (prev) => [...(prev ?? []), temp]); return { tempId: temp.id }; }, onSuccess: (row, _v, ctx) => { // reconcile temp id to real id so the // realtime append does not duplicate it qc.setQueryData(key(taskId), (prev) => prev.map((c) => c.id === ctx.tempId ? row : c)); }, onError: (_e, _v, ctx) => { qc.setQueryData(key(taskId), (prev) => prev.filter((c) => c.id !== ctx.tempId)); }, }); }
Risks & mitigations
| Risk | Sev | Mitigation |
|---|---|---|
| Realtime duplicate: socket append races with the HTTP response and the temp-id reconcile. | HIGH | Dedupe on server-assigned id in the cache updater; socket payload carries the real id, temp rows are filtered on reconcile. |
| Unread counts go stale when a user reads the thread on another device. | MED | Broadcast comment_reads upserts on the same channel; client treats its own cursor as max(local, remote). |
Mention detection false-positives on pasted markdown (@media, @2x). |
LOW | Resolve mentions against workspace members only, at write time, and store the resolved user ids - never re-parse on read. |
Open questions
Editing needs an edited_at column and an "edited" affordance. Delete-and-repost is simpler but loses the reply anchor. Leaning toward delete-only for v1.
Immediate-per-mention will be noisy. Proposal: batch on a 15-minute window, collapse to one email per task, and respect quiet hours from the existing settings table.