From 9625ecccb73dd45f2809a5994dc5f49d13338c49 Mon Sep 17 00:00:00 2001 From: Michel Jelsma Date: Wed, 28 Oct 2026 22:24:03 +0100 Subject: [PATCH 1/2] fix(backlog): honor configured completed statuses --- .../collections/CollectionBacklog.svelte | 10 +++---- .../features/collections/collectionFilters.js | 9 ++++++ .../lib/stores/collectionContext.svelte.js | 29 +++++++++++++------ 3 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/features/collections/collectionFilters.js diff --git a/frontend/src/lib/features/collections/CollectionBacklog.svelte b/frontend/src/lib/features/collections/CollectionBacklog.svelte index 547da2493..6697c467d 100644 --- a/frontend/src/lib/features/collections/CollectionBacklog.svelte +++ b/frontend/src/lib/features/collections/CollectionBacklog.svelte @@ -279,12 +279,12 @@ const membership = await resolveCreatedItemMembership(newItem, { workspaceId, collectionId, - subFilterQL: collectionStore.subFilterQL ? collectionStore.effectiveSubFilterQL : '', + subFilterQL: collectionStore.effectiveBacklogSubFilterQL, }); if (membership === 'member') { - // insertCreatedItem deduplicates by id so a repeated create event cannot - // append the same backlog row twice. - collectionStore.insertCreatedItem(newItem, { backlog: true }); + // Backlog membership also depends on configured statuses, so let the + // authoritative backlog request decide whether the new item belongs. + scheduleCollectionReconciliation(); } else if (membership === 'unknown') { scheduleCollectionReconciliation(); } @@ -439,7 +439,7 @@ const boundaryItem = await items.getBacklogBoundary( workspaceId, collectionId, - collectionStore.effectiveSubFilterQL, + collectionStore.effectiveBacklogSubFilterQL, boundary, ); if (!boundaryItem || boundaryItem.id === item.id) return; diff --git a/frontend/src/lib/features/collections/collectionFilters.js b/frontend/src/lib/features/collections/collectionFilters.js new file mode 100644 index 000000000..fbc40964b --- /dev/null +++ b/frontend/src/lib/features/collections/collectionFilters.js @@ -0,0 +1,9 @@ +/** + * Applies the optional completed-item visibility filter used by collection + * views. Backlogs pass hideCompleted=false because their configured status IDs + * are already the authoritative visibility rule. + */ +export function collectionSubFilterQL(subFilterQL = '', { hideCompleted = true } = {}) { + if (!hideCompleted) return subFilterQL; + return subFilterQL ? `(${subFilterQL}) AND status_completed = false` : 'status_completed = false'; +} diff --git a/frontend/src/lib/stores/collectionContext.svelte.js b/frontend/src/lib/stores/collectionContext.svelte.js index 424254b05..89ea7e7fd 100644 --- a/frontend/src/lib/stores/collectionContext.svelte.js +++ b/frontend/src/lib/stores/collectionContext.svelte.js @@ -2,6 +2,7 @@ import { collections } from '../api/collections.js'; import { items as itemsApi } from '../api/items.js'; import { BACKGROUND_READ, FOREGROUND_READ } from '../api/readScheduler.js'; import { normalizeCompletedLimit } from '../features/collections/boardConfigPayload.js'; +import { collectionSubFilterQL } from '../features/collections/collectionFilters.js'; import { fetchCollectionBacklog, fetchCollectionItemChanges, @@ -159,10 +160,11 @@ class CollectionStore { showCompleted = $state(false); get effectiveSubFilterQL() { - if (this.showCompleted) return this.subFilterQL; - return this.subFilterQL - ? `(${this.subFilterQL}) AND status_completed = false` - : 'status_completed = false'; + return collectionSubFilterQL(this.subFilterQL, { hideCompleted: !this.showCompleted }); + } + + get effectiveBacklogSubFilterQL() { + return collectionSubFilterQL(this.subFilterQL, { hideCompleted: false }); } #completionPreferenceKey(wsId, colId, view) { @@ -506,7 +508,7 @@ class CollectionStore { ? fetchCollectionBacklog(wsId, colId, { page: 1, limit: DEFAULT_PAGE_SIZE, - sub_ql: this.effectiveSubFilterQL || undefined, + sub_ql: this.effectiveBacklogSubFilterQL || undefined, collection, requestOptions: FOREGROUND_READ, }) @@ -1173,7 +1175,7 @@ class CollectionStore { const result = await fetchCollectionBacklog(this.#wsId, this.#colId, { page: nextPage, limit: this.backlogPagination?.limit ?? DEFAULT_PAGE_SIZE, - sub_ql: this.effectiveSubFilterQL || undefined, + sub_ql: this.effectiveBacklogSubFilterQL || undefined, requestOptions: BACKGROUND_READ, }); @@ -1288,7 +1290,7 @@ class CollectionStore { this.#wsId, this.#colId, backlogLimit, - this.effectiveSubFilterQL || undefined, + this.effectiveBacklogSubFilterQL || undefined, collection, loadId ) @@ -1514,7 +1516,10 @@ class CollectionStore { const changes = await fetchCollectionItemChanges(this.#wsId, this.#colId, { since: this.#changesWatermark, - sub_ql: this.effectiveSubFilterQL || undefined, + sub_ql: + (BACKLOG_VIEWS.has(this.#currentView) + ? this.effectiveBacklogSubFilterQL + : this.effectiveSubFilterQL) || undefined, }); if (loadId !== this.#loadId) return false; const nextWatermark = changes?.watermark ?? this.#changesWatermark; @@ -1593,7 +1598,10 @@ class CollectionStore { const wsId = this.#wsId; const colId = this.#colId; const changes = await fetchCollectionItemChanges(wsId, colId, { - sub_ql: this.effectiveSubFilterQL || undefined, + sub_ql: + (BACKLOG_VIEWS.has(this.#currentView) + ? this.effectiveBacklogSubFilterQL + : this.effectiveSubFilterQL) || undefined, }); if (loadId !== this.#loadId || wsId !== this.#wsId || colId !== this.#colId) return; this.#changesWatermark = changes?.watermark ?? 0; @@ -1610,6 +1618,9 @@ class CollectionStore { } #hideCompletedItem(item) { + // Backlog membership is defined by the configured status IDs, which may + // intentionally include completed statuses. + if (BACKLOG_VIEWS.has(this.#currentView)) return false; if (this.showCompleted) return false; const status = workspaceDataStore.statuses.find((candidate) => candidate.id === item.status_id) ?? From 7cd645353e287a235e565f749e3483932a8bf975 Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:30:12 +0100 Subject: [PATCH 2/2] fix(backlog): resolve configured statuses per collection scope The backlog endpoint resolved backlog_status_ids from the workspace query parameter, but the frontend sends only collection_id for collection backlogs. The lookup ran with workspace_id 0, skipped the board configuration, and fell back to the global non-completed statuses, so a completed status configured for a collection backlog (for example Done) was never returned. Resolve the configuration by request scope: the collection-scoped board configuration first, the workspace default for requests without a collection, and the global non-completed statuses when no configuration applies. A collection that never saved a board configuration keeps the global fallback, which matches the defaults the board configuration editor shows for that scope. --- internal/repository/item_list.go | 29 +++++++++++++++++++------- internal/services/item_crud_service.go | 5 +++-- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/internal/repository/item_list.go b/internal/repository/item_list.go index f7d70bfe7..7af1439cb 100644 --- a/internal/repository/item_list.go +++ b/internal/repository/item_list.go @@ -1159,21 +1159,34 @@ func (r *ItemRepository) scanItemList(rows *sql.Rows) ([]models.Item, error) { } // GetBacklogStatusIDs returns status IDs for backlog items. -// It first checks board_configurations for the workspace, then falls back to non-completed statuses. -func (r *ItemRepository) GetBacklogStatusIDs(workspaceID int) ([]int, error) { - return r.GetBacklogStatusIDsContext(context.Background(), workspaceID) +// It first checks board_configurations for the collection or workspace, then +// falls back to the global non-completed statuses. +func (r *ItemRepository) GetBacklogStatusIDs(workspaceID, collectionID int) ([]int, error) { + return r.GetBacklogStatusIDsContext(context.Background(), workspaceID, collectionID) } // GetBacklogStatusIDsContext is the request-aware form of GetBacklogStatusIDs. -func (r *ItemRepository) GetBacklogStatusIDsContext(ctx context.Context, workspaceID int) ([]int, error) { - // First, check if there's a board configuration with backlog_status_ids - if workspaceID > 0 { +// A collection-scoped board configuration owns that collection's backlog +// statuses; a request without a collection uses the workspace default. When +// neither is configured, the global non-completed, non-default statuses apply. +func (r *ItemRepository) GetBacklogStatusIDsContext(ctx context.Context, workspaceID, collectionID int) ([]int, error) { + scopeColumn, scopeID := "", 0 + if collectionID > 0 { + scopeColumn, scopeID = "collection_id", collectionID + } else if workspaceID > 0 { + scopeColumn, scopeID = "workspace_id", workspaceID + } + if scopeID > 0 { var backlogStatusIDsJSON sql.NullString err := r.db.QueryRowContext(ctx, ` SELECT backlog_status_ids FROM board_configurations - WHERE workspace_id = ?`, workspaceID).Scan(&backlogStatusIDsJSON) - + WHERE `+scopeColumn+` = ? + ORDER BY id + LIMIT 1`, scopeID).Scan(&backlogStatusIDsJSON) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("failed to load backlog configuration: %w", err) + } if err == nil && backlogStatusIDsJSON.Valid && backlogStatusIDsJSON.String != "" { var statusIDs []int if err := json.Unmarshal([]byte(backlogStatusIDsJSON.String), &statusIDs); err != nil { diff --git a/internal/services/item_crud_service.go b/internal/services/item_crud_service.go index 16ed03a75..3d8266c9c 100644 --- a/internal/services/item_crud_service.go +++ b/internal/services/item_crud_service.go @@ -620,7 +620,8 @@ type BacklogParams struct { OmitDescriptions bool } -// GetBacklogItems retrieves items with non-completed statuses for a workspace/collection +// GetBacklogItems retrieves items whose status is configured for the backlog +// of the requested collection or workspace. func (s *ItemCRUDService) GetBacklogItems(params BacklogParams) ([]models.Item, int, error) { return s.GetBacklogItemsContext(context.Background(), params) } @@ -632,7 +633,7 @@ func (s *ItemCRUDService) GetBacklogItemsContext(ctx context.Context, params Bac } // Resolve backlog status IDs - backlogStatusIDs, err := s.repo.GetBacklogStatusIDsContext(ctx, params.WorkspaceID) + backlogStatusIDs, err := s.repo.GetBacklogStatusIDsContext(ctx, params.WorkspaceID, params.CollectionID) if err != nil { return nil, 0, fmt.Errorf("failed to get backlog statuses: %w", err) }