Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

Last Updated: 2026-09-22

## Comment ordering retains session and loading ownership (#3303)

Comment reads publish only while their exact cache visit, latest-read version and local
mutation version still match. Successful writes invalidate older snapshots, and repeated
creates preserve an existing stable comment ID. Same-comment update/delete operations run
in intent order so the server and cache agree. Already submitted queued edits and deletes
continue across same-session navigation while the departed cache stays protected; successful
writes from a prior visit reconcile a currently reopened same-board cache after completion.

The imported ordering work also uses the shared session and loading owners from #3306/#3305.
Logout before any board loads retires queued comment transport, and old settlements or
reconciliation reads cannot publish into a new account. Queued writes retain loading until
their own settlement. Deferred tests cover the combined boundaries and preserve the source
ordering regressions. This prevents confirmed comment edits disappearing during navigation
and reduces manual refreshes without changing review-first proposal behavior. Recovery from an
unanswered old-session write holding a same-comment queue remains tracked in #3362.

## Board loading belongs to pending operations (#3305)

Board list/detail reads and the mutations that show shared loading now retain individual
Expand Down Expand Up @@ -34,8 +51,8 @@ notifications. Store results and errors still settle for the original caller.

This prevents old-account data from reappearing and reduces cleanup after account switching.
It preserves review-first proposal behavior and does not undo server writes. Same-session
loading arbitration is covered by #3305 above; card/comment ordering PRs #3312/#3304 require separate
reconciliation with these guards before integration.
loading arbitration is covered by #3305 above, and comment ordering is integrated through
#3303 above. Card ordering PR #3312 still requires separate reconciliation before integration.

## Column writes follow their board visit (#3314)

Expand Down
264 changes: 226 additions & 38 deletions frontend/taskdeck-web/src/store/board/cardCommentStore.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,216 @@
/**
* Card comment operations: fetch, create, update, delete comments.
*/
import { watch } from 'vue'
import { cardCommentsApi } from '../../api/cardCommentsApi'
import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '../../types/comments'
import type { BoardState } from './boardState'
import { beginBoardLoading, captureBoardSession, type BoardHelpers } from './boardStoreHelpers'

interface CommentCacheVisit {
boardId: string
cache: Record<string, CardComment[]>
generation: number
isCurrentSession: () => boolean
}

class StaleBoardVisitError extends Error {
constructor() {
super('The board visit that queued this comment change has ended.')
this.name = 'StaleBoardVisitError'
}
}

export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) {
// Reads and writes share one per-card cache. Cache-container identity protects
// ordinary reads, while a synchronous board-id generation distinguishes one
// visit from A→B→A. The shared session epoch also covers logout with no
// committed board. Same-board detail refreshes keep
// that generation and may therefore receive a confirmed write into their new
// cache container.
const readVersionByCardId = new Map<string, number>()
const mutationVersionByCardId = new Map<string, number>()
const mutationTailByCommentKey = new Map<string, Promise<void>>()
let boardVisitGeneration = 0

watch(
() => state.currentBoard?.value?.id ?? null,
(nextBoardId, previousBoardId) => {
if (nextBoardId !== previousBoardId) boardVisitGeneration++
},
{ flush: 'sync' },
)

function nextReadVersion(cardId: string) {
const version = (readVersionByCardId.get(cardId) ?? 0) + 1
readVersionByCardId.set(cardId, version)
return version
}

function currentMutationVersion(cardId: string) {
return mutationVersionByCardId.get(cardId) ?? 0
}

function markCommentMutation(cardId: string) {
mutationVersionByCardId.set(cardId, currentMutationVersion(cardId) + 1)
}

function captureCommentCacheVisit(boardId: string): CommentCacheVisit {
return {
boardId,
cache: state.cardCommentsByCardId.value,
generation: boardVisitGeneration,
isCurrentSession: captureBoardSession(state),
}
}

function isCurrentBoardVisit(visit: CommentCacheVisit) {
const currentBoard = state.currentBoard?.value
return (
visit.isCurrentSession() &&
(currentBoard == null || currentBoard.id === visit.boardId) &&
boardVisitGeneration === visit.generation
)
}

function ownsExactCommentCache(visit: CommentCacheVisit) {
return isCurrentBoardVisit(visit) && state.cardCommentsByCardId.value === visit.cache
}

async function runCommentMutation<T>(
cardId: string,
commentId: string,
visit: CommentCacheVisit,
mutation: () => Promise<T>,
): Promise<T> {
const key = `${cardId}:${commentId}`
const previous = mutationTailByCommentKey.get(key)
Comment thread
Chris0Jeky marked this conversation as resolved.
let operation: Promise<T>

if (previous) {
// A failed predecessor must not cancel a later user intent. It still
// settles through its own caller/error path; the next request starts
// afterward if the initiating board session still owns it.
operation = previous.catch(() => undefined).then(() => {
// The HTTP interceptor reads the token when transport starts. Reject a
// queued pre-logout intent before the API callback can run under another
// session's credentials. A same-session board change only retires cache
// publication; it must not discard an already accepted edit or delete.
if (!visit.isCurrentSession()) throw new StaleBoardVisitError()
return mutation()
Comment thread
Chris0Jeky marked this conversation as resolved.
})
} else {
// The first intent is not queued. Start its transport in the initiating
// call stack so immediate navigation cannot retroactively cancel a request
// that the UI already submitted. Only later intents wait behind a tail.
if (!isCurrentBoardVisit(visit)) throw new StaleBoardVisitError()
operation = mutation()
}

const tail = operation.then(
() => undefined,
() => undefined,
)
mutationTailByCommentKey.set(key, tail)

try {
return await operation
} finally {
if (mutationTailByCommentKey.get(key) === tail) {
mutationTailByCommentKey.delete(key)
}
}
}

async function reconcileCurrentCommentsAfterStaleVisit(
boardId: string, cardId: string, previousVisit: CommentCacheVisit,
) {
if (!previousVisit.isCurrentSession() || state.currentBoard?.value?.id !== boardId) return

const visit = captureCommentCacheVisit(boardId)
const readVersion = nextReadVersion(cardId)
const mutationVersion = currentMutationVersion(cardId)
try {
const comments = await cardCommentsApi.getComments(boardId, cardId)
if (
isCurrentBoardVisit(visit) &&
readVersionByCardId.get(cardId) === readVersion &&
currentMutationVersion(cardId) === mutationVersion
) {
// This read begins only after the write succeeded. It is authoritative
// for the currently reopened visit, while any older read is rejected by
// the version/mutation guards above.
state.cardCommentsByCardId.value[cardId] = comments
}
} catch {
if (isCurrentBoardVisit(visit)) {
helpers.toast.warning(
'Comment saved, but comments could not be refreshed. Reopen the card before editing again.',
)
}
}
}

function getCardComments(cardId: string): CardComment[] {
return state.cardCommentsByCardId.value[cardId] ?? []
}

async function fetchCardComments(boardId: string, cardId: string) {
if (helpers.isDemoMode) return []
const isCurrentSession = captureBoardSession(state)
const visit = captureCommentCacheVisit(boardId)
const readVersion = nextReadVersion(cardId)
const mutationVersion = currentMutationVersion(cardId)
try {
const comments = await cardCommentsApi.getComments(boardId, cardId)
if (!isCurrentSession()) return comments
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
[cardId]: comments,
// A newer read owns the cache. A successful local mutation also
// invalidates every snapshot that began before it, even when that older
// request returns later. The payload is still returned to its caller.
if (
ownsExactCommentCache(visit) &&
readVersionByCardId.get(cardId) === readVersion &&
currentMutationVersion(cardId) === mutationVersion
) {
visit.cache[cardId] = comments
}
return comments
} catch (e: unknown) {
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to fetch card comments')
if (ownsExactCommentCache(visit)) {
helpers.handleApiError(e, 'Failed to fetch card comments')
}
throw e
}
}

async function createCardComment(boardId: string, cardId: string, dto: CreateCardCommentDto) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const visit = captureCommentCacheVisit(boardId)
const finishLoading = beginBoardLoading(state)
try {
state.error.value = null
const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto)
if (!isCurrentSession()) return createdComment
const existingComments = state.cardCommentsByCardId.value[cardId] ?? []
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
[cardId]: [...existingComments, createdComment].sort(
(left, right) =>
new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(),
),
}
if (!visit.isCurrentSession()) return createdComment
markCommentMutation(cardId)

helpers.toast.success('Comment added')
if (isCurrentBoardVisit(visit)) {
const currentCache = state.cardCommentsByCardId.value
const existingComments = currentCache[cardId] ?? []
// A same-board refresh can commit the stable id before this response
// arrives. Preserve that fresher object instead of appending a duplicate.
if (!existingComments.some(comment => comment.id === createdComment.id)) {
currentCache[cardId] = [...existingComments, createdComment].sort(
(left, right) =>
new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(),
)
}
helpers.toast.success('Comment added')
} else if (state.currentBoard?.value?.id === boardId) {
await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId, visit)
}
return createdComment
} catch (e: unknown) {
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card comment')
if (isCurrentBoardVisit(visit)) {
helpers.handleApiError(e, 'Failed to create card comment')
}
throw e
} finally {
finishLoading()
Expand All @@ -62,24 +224,37 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
dto: UpdateCardCommentDto,
) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const visit = captureCommentCacheVisit(boardId)
const finishLoading = beginBoardLoading(state)
try {
state.error.value = null
const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto)
if (!isCurrentSession()) return updatedComment
const existingComments = state.cardCommentsByCardId.value[cardId] ?? []
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
[cardId]: existingComments.map((comment) =>
// The API has no revision/If-Match field. Serialize same-comment writes so
// server commit order follows user intent order; filtering a late success
// client-side would otherwise let the server silently keep the older edit.
const updatedComment = await runCommentMutation(
cardId,
commentId,
visit,
() => cardCommentsApi.updateComment(boardId, cardId, commentId, dto),
)
if (!visit.isCurrentSession()) return updatedComment
markCommentMutation(cardId)

if (isCurrentBoardVisit(visit)) {
const currentCache = state.cardCommentsByCardId.value
const existingComments = currentCache[cardId] ?? []
currentCache[cardId] = existingComments.map((comment) =>
comment.id === commentId ? updatedComment : comment,
),
)
helpers.toast.success('Comment updated')
} else if (state.currentBoard?.value?.id === boardId) {
await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId, visit)
}

helpers.toast.success('Comment updated')
return updatedComment
} catch (e: unknown) {
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update card comment')
if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) {
helpers.handleApiError(e, 'Failed to update card comment')
}
throw e
} finally {
finishLoading()
Expand All @@ -88,20 +263,33 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper

async function deleteCardComment(boardId: string, cardId: string, commentId: string) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const visit = captureCommentCacheVisit(boardId)
const finishLoading = beginBoardLoading(state)
try {
state.error.value = null
await cardCommentsApi.deleteComment(boardId, cardId, commentId)
if (!isCurrentSession()) return
const existingComments = state.cardCommentsByCardId.value[cardId] ?? []
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
[cardId]: existingComments.filter((comment) => comment.id !== commentId),
await runCommentMutation(
cardId,
commentId,
visit,
() => cardCommentsApi.deleteComment(boardId, cardId, commentId),
)
if (!visit.isCurrentSession()) return
markCommentMutation(cardId)

if (isCurrentBoardVisit(visit)) {
const currentCache = state.cardCommentsByCardId.value
const existingComments = currentCache[cardId] ?? []
currentCache[cardId] = existingComments.filter(
(comment) => comment.id !== commentId,
)
helpers.toast.success('Comment deleted')
} else if (state.currentBoard?.value?.id === boardId) {
await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId, visit)
}
helpers.toast.success('Comment deleted')
} catch (e: unknown) {
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to delete card comment')
if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) {
helpers.handleApiError(e, 'Failed to delete card comment')
}
throw e
} finally {
finishLoading()
Expand Down
Loading
Loading