Ship in four slices, each independently reviewable and each behind the flag. Nothing is user-visible until slice 4.
New comments and comment_reads tables, migrations, and the tRPC router stubs. No UI. Contract reviewed before anything else lands.
Static <CommentThread> rendered from fixtures. Optimistic insert on submit, rollback on failure, one level of nesting only.
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.
Mention detection → notification row, email digest fallback, ramp task_comments_v1 to internal, then 10% → 100% over three days.
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.
Solid = request/response path. Dashed clay = realtime fan-out. The composer never waits on the dashed path.
Not pixel-final — just enough that the reviewer and I agree on nesting depth, composer placement, and what the sidebar digest looks like.
projects.count > 0 and fall back to the minimal variant.The two pieces most likely to be done wrong: the migration (soft deletes, read cursors) and the optimistic mutation (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 → 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)); }, }); }
id in the cache updater; socket payload carries the real id, temp rows are filtered on reconcile.comment_reads upserts on the same channel; client treats its own cursor as max(local, remote).@media, @2x).edited_at column and an "edited" affordance. Delete-and-repost is simpler but loses the reply anchor. Leaning toward delete-only for v1.