Skip to content
Draft
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
237 changes: 162 additions & 75 deletions frontend/taskdeck-web/src/store/board/cardStore.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,102 @@
/**
* Card operations: fetch, create, update, delete, move cards, and provenance.
*/
import { watch } from 'vue'
import { cardsApi } from '../../api/cardsApi'
import { getErrorMessage } from '../../utils/errorMessage'
import type { CardDetachPreview, CreateCardDto, UpdateCardDto, CardCaptureProvenance } from '../../types/board'
import type { BoardState } from './boardState'
import type { BoardHelpers } from './boardStoreHelpers'
import type { BoardFetchOptions } from './boardCrudStore'

interface CardMutationVisit {
boardId: string
generation: number
}

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

export function createCardActions(
state: BoardState,
helpers: BoardHelpers,
refreshBoard: (boardId: string, options?: BoardFetchOptions) => Promise<boolean>,
) {
// Move and delete target the same durable card and neither API exposes a
// shared client mutation token. Serialize only that per-card lane so server
// commit order follows user intent, while unrelated cards remain concurrent.
// The visit generation prevents a queued pre-logout intent from starting
// under a later session's credentials.
const mutationTailByCardId = new Map<string, Promise<void>>()
let boardVisitGeneration = 0

watch(
() => state.currentBoard.value?.id ?? null,
(nextBoardId, previousBoardId) => {
if (nextBoardId !== previousBoardId) boardVisitGeneration++
Comment on lines +38 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate queued mutations when the route visit ends

When a second same-card mutation is queued and the user navigates from board A while the first request is pending, this watcher may not advance the generation because currentBoard remains the last committed board until the next board fetch succeeds, and BoardView also leaves it intact on unmount. If the first request settles during a slow board-B load—or after leaving the board route—the queued mutation still passes the visit check and starts against A, despite belonging to the abandoned visit; bind invalidation to the route/fetch visit lifecycle rather than the committed board payload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against exact head 8f89bfc: BoardView's route watcher changes its local boardId before awaiting fetchBoard, while the store's mutation watcher observes only currentBoard.id. On unmount the view cancels a background fetch and clears presence/editing state, but does not retire this mutation-visit generation. startBoardFetch advances its own request generation before transport without changing the committed payload. Thus the queued A intent can still start while B is pending or the board route has closed.

No fix for this new finding is claimed by the fixture-only commit. Keep the thread open and PR draft. A separate explicit route/mutation-visit boundary must retire queued intent before a new foreground route load or view teardown, while preserving ordinary same-board background refreshes and already-dispatched transport. Add integration-shaped deferred tests for slow/failed B loads, route unmount and A-to-B-to-A, including no extra API call under the abandoned owner. Simply observing every board-fetch generation would incorrectly cancel legitimate same-board refresh work.

},
{ flush: 'sync' },
)

function captureCardMutationVisit(boardId: string): CardMutationVisit {
return { boardId, generation: boardVisitGeneration }
}

function isCurrentCardMutationVisit(visit: CardMutationVisit) {
const currentBoard = state.currentBoard.value
return (
(currentBoard === null || currentBoard.id === visit.boardId) &&
boardVisitGeneration === visit.generation
)
}

async function runCardMutation<T>(
cardId: string,
visit: CardMutationVisit,
mutation: () => Promise<T>,
): Promise<T> {
const previous = mutationTailByCardId.get(cardId)
let operation: Promise<T>

if (previous) {
operation = previous.catch(() => undefined).then(() => {
if (!isCurrentCardMutationVisit(visit)) throw new StaleBoardVisitError()
return mutation()
})
} else {
// The first intent is already submitted by the caller; do not defer its
// transport to a microtask where immediate navigation could cancel it.
if (!isCurrentCardMutationVisit(visit)) throw new StaleBoardVisitError()
operation = mutation()
}

const tail = operation.then(
() => undefined,
() => undefined,
)
mutationTailByCardId.set(cardId, tail)

try {
return await operation
} finally {
if (mutationTailByCardId.get(cardId) === tail) {
mutationTailByCardId.delete(cardId)
}
}
}

function isOlderCardSnapshot(candidateUpdatedAt: string, currentUpdatedAt: string) {
const candidateTime = Date.parse(candidateUpdatedAt)
const currentTime = Date.parse(currentUpdatedAt)
return Number.isFinite(candidateTime) &&
Number.isFinite(currentTime) &&
candidateTime < currentTime
Comment on lines +93 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve full timestamp precision when rejecting stale snapshots

When a refresh installs a newer card less than one millisecond after the pending move, Date.parse collapses the backend's seven-digit DateTimeOffset fractions to the same millisecond (for example, .1234567Z and .1234999Z compare equal). isOlderCardSnapshot therefore accepts the stale move response, which then overwrites the authoritative card and adjusts column counts from the newer state; compare the server version without discarding sub-millisecond precision.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The current helper reduces both 2026-09-22T00:00:00.1234567Z and 2026-09-22T00:00:00.1234999Z to 1790035200123; the older-than predicate therefore returns false. Reproduced that exact precision loss locally in Node, without treating it as full project execution.

The 8f89bfc commit only reconciles the two older fixtures; it does not address this new finding. Leaving this thread unresolved and the PR draft. The correction needs a precision-preserving instant comparison, not lexical comparison of unnormalised offset strings, plus canonical pending-move/authoritative-refresh tests at sub-millisecond differences, equal instants with different offsets, and normal newer/equal controls. Card payload and column counts must both remain authoritative when the older response settles.

}

async function refreshDetachedChildren(boardId: string) {
// The mutation already committed. It only changes hierarchy ownership, not
// surviving comment threads, so keep the open editor's same-board cache
Expand Down Expand Up @@ -130,44 +214,44 @@ export function createCardActions(

async function deleteCard(boardId: string, cardId: string, confirmation?: CardDetachPreview) {
helpers.guardDemoMutation()
let refreshChildren = false
try {
state.loading.value = true
state.error.value = null
await cardsApi.deleteCard(boardId, cardId, confirmation)
helpers.markBoardDetailMutation(boardId)
const visit = captureCardMutationVisit(boardId)
return runCardMutation(cardId, visit, async () => {
let refreshChildren = false
try {
state.loading.value = true
state.error.value = null
await cardsApi.deleteCard(boardId, cardId, confirmation)
helpers.markBoardDetailMutation(boardId)

// A move, realtime refresh, or navigation can replace this state while the
// DELETE is in flight. Commit only into the initiating board's current
// collection, and derive the count delta from the card that exists NOW.
// If an authoritative refresh already removed it, its count is already
// settled and must not be decremented again.
const ownsCurrentCards =
state.currentBoard.value === null || state.currentBoard.value.id === boardId
if (ownsCurrentCards) {
const committedCard = state.currentBoardCards.value.find((card) => card.id === cardId)
state.currentBoardCards.value = state.currentBoardCards.value.filter((card) => card.id !== cardId)
if (state.cardCommentsByCardId.value[cardId]) {
const { [cardId]: _, ...remainingComments } = state.cardCommentsByCardId.value
state.cardCommentsByCardId.value = remainingComments
}
// A move, realtime refresh, or navigation can replace this state while
// the DELETE is in flight. Commit only into the exact initiating board
// visit and derive the count delta from the card that exists NOW. If an
// authoritative refresh already removed it, its count is already settled.
if (isCurrentCardMutationVisit(visit)) {
const committedCard = state.currentBoardCards.value.find((card) => card.id === cardId)
state.currentBoardCards.value = state.currentBoardCards.value.filter((card) => card.id !== cardId)
if (state.cardCommentsByCardId.value[cardId]) {
const { [cardId]: _, ...remainingComments } = state.cardCommentsByCardId.value
state.cardCommentsByCardId.value = remainingComments
}

if (committedCard) {
helpers.updateColumnCardCount(committedCard.columnId, -1)
}
if (committedCard) {
helpers.updateColumnCardCount(committedCard.columnId, -1)
}

refreshChildren = state.currentBoard.value?.id === boardId &&
state.currentBoardCards.value.some(card => card.parentCardId === cardId)
refreshChildren = state.currentBoard.value?.id === boardId &&
state.currentBoardCards.value.some(card => card.parentCardId === cardId)
helpers.toast.success('Card deleted successfully')
}
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to delete card')
throw e
} finally {
state.loading.value = false
}
helpers.toast.success('Card deleted successfully')
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to delete card')
throw e
} finally {
state.loading.value = false
}
// Finish mutation-owned loading/error writes before a refresh can outlive navigation.
if (refreshChildren) await refreshDetachedChildren(boardId)
// Finish mutation-owned loading/error writes before a refresh can outlive navigation.
if (refreshChildren) await refreshDetachedChildren(boardId)
})
}

async function moveCard(
Expand All @@ -177,53 +261,56 @@ export function createCardActions(
targetPosition: number,
) {
helpers.guardDemoMutation()
try {
state.loading.value = true
state.error.value = null
const visit = captureCardMutationVisit(boardId)
return runCardMutation(cardId, visit, async () => {
try {
state.loading.value = true
state.error.value = null

const existingCard =
state.currentBoardCards.value.find((c) => c.id === cardId) ?? null
const previousColumnId = existingCard?.columnId ?? null
const updatedCard = await cardsApi.moveCard(boardId, cardId, {
targetColumnId,
targetPosition,
})
helpers.markBoardDetailMutation(boardId)
const updatedCard = await cardsApi.moveCard(boardId, cardId, {
targetColumnId,
targetPosition,
})
helpers.markBoardDetailMutation(boardId)

// The board can change while the move is in flight. Committing to another
// board's array would splice an unrelated card out and push this one in.
// Skip only when a board IS selected and it is a different one; a null
// currentBoard still owns currentBoardCards (integration tests and the
// pre-load window).
if (state.currentBoard.value && state.currentBoard.value.id !== boardId) {
return updatedCard
}
if (!isCurrentCardMutationVisit(visit)) {
return updatedCard
}

// Re-resolve by id AFTER the await, exactly as updateCard does. An index
// captured before the await goes stale whenever anything else mutates the
// array first -- a second concurrent move, a realtime-triggered refetch, a
// teammate's delete -- and splicing it removes the WRONG card: the moved
// card survives as a duplicate while an innocent one disappears.
const commitIndex = state.currentBoardCards.value.findIndex((c) => c.id === cardId)
if (commitIndex !== -1) {
state.currentBoardCards.value.splice(commitIndex, 1)
}
// Resolve the committed card after the await. Its current column owns
// any count delta; a pre-request snapshot has no settlement authority.
const commitIndex = state.currentBoardCards.value.findIndex((card) => card.id === cardId)
if (commitIndex === -1) {
// A later delete or authoritative refresh removed the card. Never
// resurrect it from an older move response. Preserve the historical
// null-board preload behavior only when no board session exists yet.
if (state.currentBoard.value === null && state.currentBoardCards.value.length === 0) {
state.currentBoardCards.value.push(updatedCard)
helpers.toast.success('Card moved successfully')
}
return updatedCard
}

state.currentBoardCards.value.push(updatedCard)
const committedCard = state.currentBoardCards.value[commitIndex]
if (isOlderCardSnapshot(updatedCard.updatedAt, committedCard.updatedAt)) {
return updatedCard
}

if (previousColumnId && previousColumnId !== updatedCard.columnId) {
helpers.updateColumnCardCount(previousColumnId, -1)
helpers.updateColumnCardCount(updatedCard.columnId, 1)
}
state.currentBoardCards.value[commitIndex] = updatedCard
if (committedCard.columnId !== updatedCard.columnId) {
helpers.updateColumnCardCount(committedCard.columnId, -1)
helpers.updateColumnCardCount(updatedCard.columnId, 1)
}

helpers.toast.success('Card moved successfully')
return updatedCard
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to move card')
throw e
} finally {
state.loading.value = false
}
helpers.toast.success('Card moved successfully')
return updatedCard
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to move card')
throw e
} finally {
state.loading.value = false
}
})
}

async function fetchCardProvenance(
Expand Down
40 changes: 30 additions & 10 deletions frontend/taskdeck-web/src/tests/store/board/cardStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,27 +445,45 @@ describe('cardStore', () => {
*/
it('re-resolves the card by id after the await, so a shifted array cannot splice the wrong card', async () => {
state.currentBoard.value!.columns.push({ id: 'col-2', name: 'Done', cardCount: 0 })
const unrelatedCard = { ...state.currentBoardCards.value[1] }
// Baseline: card-1 at index 0, card-2 at index 1.
expect(state.currentBoardCards.value.map((c: { id: string }) => c.id)).toEqual(['card-1', 'card-2'])

const movedCard = {
...state.currentBoardCards.value[0], id: 'card-1', columnId: 'col-2', updatedAt: '2024-01-06T00:00:00Z',
}
mockCardsApi.moveCard.mockImplementationOnce(async () => {
// While the move is in flight, card-1 is removed by something else (a
// realtime refetch, a teammate's delete). The pre-await index 0 now
// points at card-2 -- an innocent bystander.
state.currentBoardCards.value.shift()
// A refresh reorders the array while retaining both cards. The saved
// pre-await index now points at card-2, not the requested card.
state.currentBoardCards.value.reverse()
return movedCard
})

const { moveCard } = createCardActions(state as any, helpers as any, vi.fn().mockResolvedValue(true))
await moveCard('board-1', 'card-1', 'col-2', 0)

// card-2 must survive. Against the stale-index commit it was spliced out
// and the array came back as ['card-1'] alone.
// Exact IDs catch both loss of the unrelated card and duplication of the target.
const ids = state.currentBoardCards.value.map((c: { id: string }) => c.id).sort()
expect(ids).toEqual(['card-1', 'card-2'])
expect(state.currentBoardCards.value.find((card) => card.id === 'card-1')).toEqual(movedCard)
expect(state.currentBoardCards.value.find((card) => card.id === 'card-2')).toEqual(unrelatedCard)
})

it('does not resurrect a card removed by an authoritative refresh while its move is pending', async () => {
const unrelatedCard = { ...state.currentBoardCards.value[1] }
const movedCard = { ...state.currentBoardCards.value[0], columnId: 'col-2' }
mockCardsApi.moveCard.mockImplementationOnce(async () => {
state.currentBoardCards.value.shift()
return movedCard
})
const { moveCard } = createCardActions(state as any, helpers as any, vi.fn().mockResolvedValue(true))

const result = await moveCard('board-1', 'card-1', 'col-2', 0)

expect(result).toEqual(movedCard)
expect(state.currentBoardCards.value).toEqual([unrelatedCard])
expect(helpers.updateColumnCardCount).not.toHaveBeenCalled()
expect(helpers.toast.success).not.toHaveBeenCalled()
})

it('still commits into currentBoardCards when currentBoard is unset', async () => {
Expand Down Expand Up @@ -495,7 +513,7 @@ describe('cardStore', () => {
expect(state.currentBoardCards.value.map((c: { id: string }) => c.id)).toEqual(['other-board-card'])
})

it('removes from old position and pushes updated card', async () => {
it('replaces the moved card by stable id without changing unrelated cards', async () => {
const movedCard = {
id: 'card-1',
boardId: 'board-1',
Expand All @@ -522,9 +540,11 @@ describe('cardStore', () => {
})
expect(result).toEqual(movedCard)
expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1')
expect(state.currentBoardCards.value[state.currentBoardCards.value.length - 1]).toEqual(
movedCard,
)
expect(state.currentBoardCards.value.filter((card) => card.id === 'card-1')).toEqual([movedCard])
expect(state.currentBoardCards.value.find((card) => card.id === 'card-2')).toMatchObject({
title: 'Second', columnId: 'col-1', position: 1,
})
expect(state.currentBoardCards.value).toHaveLength(2)
expect(helpers.toast.success).toHaveBeenCalledWith('Card moved successfully')
expect(state.loading.value).toBe(false)
})
Expand Down
Loading
Loading