Implementation plan · Birchline web client

Comment threads on task cards

Prompt 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.
Effort
~2 weeks
Surfaces touched
3 packages
New tables
2
Feature flag
task_comments_v1
01

Milestones

Ship in four slices, each independently reviewable and each behind the flag. Nothing is user-visible until slice 4.

Week 1 · Mon–Tue

Schema & API contract

New comments and comment_reads tables, migrations, and the tRPC router stubs. No UI. Contract reviewed before anything else lands.

packages/dbpackages/apimigration 0042
Week 1 · Wed–Fri

Thread component & composer

Static <CommentThread> rendered from fixtures. Optimistic insert on submit, rollback on failure, one level of nesting only.

apps/webstorybook
Week 2 · Mon–Wed

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.

packages/realtimeapps/web
Week 2 · Thu–Fri

Notifications, flag ramp, docs

Mention detection → notification row, email digest fallback, ramp task_comments_v1 to internal, then 10% → 100% over three days.

packages/notifygrowthbook
02

Data flow

Optimistic write path on the left, fan-out on the right. The read cursor update is fire-and-forget — we never block the thread render on it.

<CommentComposer> apps/web React Query cache optimistic insert comments.create tRPC · packages/api comments table postgres · packages/db realtime channel task:{id}:comments Other viewers subscribed cards notify worker @mentions → queue submit (id=temp) mutate INSERT + read cursor enqueue broadcast row live append reconcile temp id → real id

Solid = request/response path. Dashed clay = realtime fan-out. The composer never waits on the dashed path.

03

Mockups

Not pixel-final — just enough that the reviewer and I agree on nesting depth, composer placement, and what the sidebar digest looks like.

A · Thread inside an open task card
Ship onboarding empty-state rewrite
BIR-1142 · Assigned to Priya · Due Fri
JM
Jonah M. 2h ago
Should the illustration swap when the workspace already has one project? Feels odd to show the "start here" art twice.
PS
Priya S. 40m ago
Good catch — I'll gate it on projects.count > 0 and fall back to the minimal variant.
Add a comment…
Post
B · Sidebar unread digest
JM
Jonah commented on BIR-1142 — "Should the illustration swap when…"
AK
Aiko mentioned you on BIR-1098 — "@priya can you confirm the copy here?"
RW
Rowan replied on BIR-0971 — "Merged, thanks for the quick turnaround."
04

Key code

The two pieces most likely to be done wrong: the migration (soft deletes, read cursors) and the optimistic mutation (temp-id reconciliation).

packages/db/migrations/0042_comments.sql
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);
apps/web/hooks/useAddComment.ts
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 → real id so the
      // realtime append doesn't 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));
    },
  });
}
05

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

Open questions

Do we allow editing, or only delete-and-repost?
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.
Decide with · design, before slice 2
Email digest cadence when a user has the app closed
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.
Decide with · platform, before slice 4