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
5 changes: 5 additions & 0 deletions OUTSTANDING_TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ to move from line 667 to 673. The expression, column bounds and negative checks
This small test-data change still touches a declared control path; independent review and passing
CI do not supply the maintainer decision under ADR-0066.

The successor for [#3305](https://github.com/Chris0Jeky/Taskdeck/issues/3305) needs the same
maintainer review: replacing shared loading moves that unchanged mutation target from line
673 to 656, including the literal companion assertion under `scripts/ci/`. Its review and
verification remain separate from approval to merge.

- [ ] **Review PR #2787 post hoc and review new CI-control candidates before merge.** The coordinator merged prompt-v3 PR #2787 at `0cebd938d79f045a20ce99bff495b986c25cf267` with hosted checks and independent Terra review, but without the maintainer review required by the ADR-0066 amendment. Its changed surface includes the Windows archive acceptance script and matching tests. The earlier SC-10 delegation covered twelve named PRs and did not include #2787. Please review that merged change; the coordinator has not inferred acknowledgement or reverted it. New nightly observation PR #2791 and the #2335 control-trust test PR must finish independent review and exact-head hosted qualification before the maintainer reviews their final heads. This checkpoint grants no release, repository-settings, or selective-execution approval.

### J.3. Twelve control-plane PRs merged outside the ADR-0066 per-PR review (2026-09-09 to 2026-09-10)
Expand Down
19 changes: 17 additions & 2 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

Last Updated: 2026-09-22

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

Board list/detail reads and the mutations that show shared loading now retain individual
owners. One request finishing cannot dismiss the skeleton or enable retries while another
operation remains pending. Filtered list reads and queued column/label writes participate
in the same ownership set; joining an existing list request does not add another owner.
Logout clears the set synchronously through the existing session generation, and late
settlements cannot clear a new account's work. Replacing an explicit detail read releases
its old owner even when the transport ignores cancellation. Background refreshes, direct
child reads and archive/restore retain their existing behavior without shared loading.

Deferred tests cover cross-module overlap, rejection, logout, independent list requests,
detail replacement and queued background work. This reduces misleading empty/loading
states during board maintenance while preserving review-first proposal behavior.

## Logout retires board mutation settlements (#3306)

Board, card (including archive/restore), label and comment operations capture the shared
Expand All @@ -19,7 +34,7 @@ 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 remains #3305; card/comment ordering PRs #3312/#3304 require separate
loading arbitration is covered by #3305 above; card/comment ordering PRs #3312/#3304 require separate
reconciliation with these guards before integration.

## Column writes follow their board visit (#3314)
Expand All @@ -37,7 +52,7 @@ for that recovery and keeps its later result.
This reduces navigation-induced board maintenance while preserving existing review-first
proposal behavior. It does not cancel a write already accepted by the server. Shared ownership
for card, comment, label and board mutations is covered by #3306 above; shared loading arbitration
remains tracked in #3305. Deferred-response store tests and BoardView lifecycle tests cover the
is covered by #3305 above. Deferred-response store tests and BoardView lifecycle tests cover the
route/session boundary, and the existing three ordering assertions now compare actual reactive
array identities as well as full contents.

Expand Down
66 changes: 25 additions & 41 deletions frontend/taskdeck-web/src/store/board/boardCrudStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { buildDemoBoardList } from '../../utils/demoData'
import { applyBoardCardCounts } from '../../utils/boardCardCounts'
import type { CreateBoardDto, UpdateBoardDto } from '../../types/board'
import { initialCardFilters, type BoardState } from './boardState'
import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers'
import { beginBoardLoading, captureBoardSession, type BoardHelpers } from './boardStoreHelpers'

// Minimum gap between board-list fetches. Multiple views (BoardsListView,
// ActivityView, ReviewView, etc.) can call fetchBoards on mount in quick
Expand Down Expand Up @@ -76,6 +76,7 @@ interface ActiveBoardFetch {
backgroundFailureMessage?: string
preserveCardComments: boolean
controller: AbortController
finishLoading: () => void
promise: Promise<boolean>
}

Expand Down Expand Up @@ -129,21 +130,24 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
return
}
if (helpers.isDemoMode) {
state.loading.value = true
state.error.value = null
state.boards.value = buildDemoBoardList()
state.loading.value = false
const finishLoading = beginBoardLoading(state)
try {
state.error.value = null
state.boards.value = buildDemoBoardList()
} finally {
finishLoading()
}
return
}

const requestGeneration = boardListGeneration
const isCurrentListGeneration = () => requestGeneration === boardListGeneration
const controller = new AbortController()
inFlightBoardListReads.add(controller)
const finishLoading = beginBoardLoading(state)

const request = (async () => {
try {
state.loading.value = true
state.error.value = null
// Bounded exactly like the detail read below (`startBoardFetch`), and
// for a reason the share made sharper: once every unfiltered caller in
Expand Down Expand Up @@ -262,25 +266,7 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
throw e
} finally {
inFlightBoardListReads.delete(controller)
// Gated for the same reason the detail path gates its own loading
// write: by the time a superseded read settles, the flag belongs to
// the read that replaced it. Clearing it here would drop the next
// session's skeleton and show that user an empty account until their
// own read resolves.
//
// That makes the gate correct only while every bumper of
// boardListGeneration also clears state.loading in the same synchronous
// turn, so no read is left owning a flag nobody will clear.
// resetForLogout is the only bumper today and does exactly that. A
// list-side cancel helper modelled on cancelBackgroundBoardFetch —
// which bumps boardFetchGeneration and deliberately leaves the flag
// alone — would strand loading true and leave BoardsListView on its
// skeleton for good. Clear the flag alongside any new bumper, or
// replace this gate with a per-request ownership token that does not
// depend on the coupling.
if (isCurrentListGeneration()) {
state.loading.value = false
}
finishLoading()
}
})()

Expand Down Expand Up @@ -425,6 +411,10 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
// recovery uses it to reject a server response that was already in flight
// when the write was refused.
state.currentBoardRequestGeneration.value = requestGeneration
const finishLoading = intent === 'explicit' ? beginBoardLoading(state) : () => {}
// Retire the old visible owner even if its transport ignores cancellation.
// Acquire the replacement first so an explicit-to-explicit handoff stays busy.
activeBoardFetch?.finishLoading()
activeBoardFetch?.controller.abort()
const controller = new AbortController()
const mutationEpoch = helpers.getBoardDetailMutationEpoch(id)
Expand All @@ -435,6 +425,7 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
backgroundFailureMessage,
preserveCardComments,
controller,
finishLoading,
promise: Promise.resolve(false),
} satisfies ActiveBoardFetch

Expand Down Expand Up @@ -472,7 +463,6 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
const performFetch = async (): Promise<boolean> => {
if (helpers.isDemoMode) {
if (intent === 'explicit') {
state.loading.value = true
state.error.value = null
}
const [board, cards] = await Promise.all([
Expand All @@ -493,15 +483,11 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
state.currentBoardCards.value = cards
state.currentBoardLabels.value = []
if (!preserveCurrentComments) state.cardCommentsByCardId.value = {}
if (intent === 'explicit') {
state.loading.value = false
}
return true
}

try {
if (intent === 'explicit') {
state.loading.value = true
state.error.value = null
}
const readOptions: BoardReadOptions = {
Expand Down Expand Up @@ -572,14 +558,11 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)

helpers.handleApiError(e, 'Failed to fetch board')
throw e
} finally {
if (intent === 'explicit' && isCurrentGeneration()) {
state.loading.value = false
}
}
}

const promise = performFetch().finally(() => {
finishLoading()
if (activeBoardFetch !== request) {
return
}
Expand All @@ -595,8 +578,8 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
async function createBoard(board: CreateBoardDto) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const finishLoading = beginBoardLoading(state)
try {
state.loading.value = true
state.error.value = null
const newBoard = await boardsApi.createBoard(board)
if (!isCurrentSession()) return newBoard
Expand All @@ -607,15 +590,15 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create board')
throw e
} finally {
if (isCurrentSession()) state.loading.value = false
finishLoading()
}
}

async function updateBoard(boardId: string, board: UpdateBoardDto) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const finishLoading = beginBoardLoading(state)
try {
state.loading.value = true
state.error.value = null
const updatedBoard = await boardsApi.updateBoard(boardId, board)
if (!isCurrentSession()) return updatedBoard
Expand All @@ -640,15 +623,15 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update board')
throw e
} finally {
if (isCurrentSession()) state.loading.value = false
finishLoading()
}
}

async function deleteBoard(boardId: string) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const finishLoading = beginBoardLoading(state)
try {
state.loading.value = true
state.error.value = null
await boardsApi.deleteBoard(boardId)
if (!isCurrentSession()) return
Expand Down Expand Up @@ -682,7 +665,7 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to archive board')
throw e
} finally {
if (isCurrentSession()) state.loading.value = false
finishLoading()
}
}

Expand Down Expand Up @@ -715,6 +698,8 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
*/
function resetForLogout() {
state.boardMutationSessionGeneration.value++
state.loadingOperations.clear()
state.loading.value = false
state.boardViewVisit.value = { boardId: null }
boardListGeneration++
// The bump comes first so the rejection each abort produces lands on a
Expand Down Expand Up @@ -753,7 +738,6 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
state.cardCommentsByCardId.value = {}
state.boardPresenceMembers.value = []
state.editingCardId.value = null
state.loading.value = false
state.error.value = null
state.filters.value = initialCardFilters()
}
Expand Down
3 changes: 3 additions & 0 deletions frontend/taskdeck-web/src/store/board/boardState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export function createBoardState() {
const boardPresenceMembers = ref<BoardPresenceMember[]>([])
const editingCardId = ref<string | null>(null)
const loading = ref(false)
// Not reactive: owners publish the visible flag when they enter or leave.
const loadingOperations = new Set<symbol>()
const error = ref<string | null>(null)

const filters = ref<CardFilters>(initialCardFilters())
Expand All @@ -81,6 +83,7 @@ export function createBoardState() {
boardPresenceMembers,
editingCardId,
loading,
loadingOperations,
error,
filters,
}
Expand Down
12 changes: 12 additions & 0 deletions frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ export function captureBoardSession(state: BoardState): () => boolean {
return () => state.boardMutationSessionGeneration.value === generation
}

/** Release only this operation; logout retires every owner through the same session epoch. */
export function beginBoardLoading(state: BoardState): () => void {
const isCurrentSession = captureBoardSession(state)
const token = Symbol()
state.loadingOperations.add(token)
state.loading.value = true
return () => {
if (!isCurrentSession() || !state.loadingOperations.delete(token)) return
state.loading.value = state.loadingOperations.size > 0
}
}

/**
* Whether this failure is a client-side timeout — a routine outcome on every
* board read since #2685 bounded them (`timeout: BOARD_REQUEST_TIMEOUT_MS`,
Expand Down
14 changes: 7 additions & 7 deletions frontend/taskdeck-web/src/store/board/cardCommentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { cardCommentsApi } from '../../api/cardCommentsApi'
import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '../../types/comments'
import type { BoardState } from './boardState'
import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers'
import { beginBoardLoading, captureBoardSession, type BoardHelpers } from './boardStoreHelpers'

export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) {
function getCardComments(cardId: string): CardComment[] {
Expand All @@ -31,8 +31,8 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
async function createCardComment(boardId: string, cardId: string, dto: CreateCardCommentDto) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const finishLoading = beginBoardLoading(state)
try {
state.loading.value = true
state.error.value = null
const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto)
if (!isCurrentSession()) return createdComment
Expand All @@ -51,7 +51,7 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card comment')
throw e
} finally {
if (isCurrentSession()) state.loading.value = false
finishLoading()
}
}

Expand All @@ -63,8 +63,8 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const finishLoading = beginBoardLoading(state)
try {
state.loading.value = true
state.error.value = null
const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto)
if (!isCurrentSession()) return updatedComment
Expand All @@ -82,15 +82,15 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update card comment')
throw e
} finally {
if (isCurrentSession()) state.loading.value = false
finishLoading()
}
}

async function deleteCardComment(boardId: string, cardId: string, commentId: string) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
const finishLoading = beginBoardLoading(state)
try {
state.loading.value = true
state.error.value = null
await cardCommentsApi.deleteComment(boardId, cardId, commentId)
if (!isCurrentSession()) return
Expand All @@ -104,7 +104,7 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to delete card comment')
throw e
} finally {
if (isCurrentSession()) state.loading.value = false
finishLoading()
}
}

Expand Down
Loading
Loading