From b581a97c2c6d3cebdca93b73b2cb9a6097989877 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Thu, 17 Sep 2026 03:51:40 +0200 Subject: [PATCH] feat: Show how many cards a list holds A board level switch, off by default. With it on, each list puts its card count on the add-card button. Useful to a board that is being kept to a size and noise to one that is not, which is why it is a choice rather than always on. The count is every card in the list, not the ones a filter leaves visible. Those answer different questions: how full is this list, versus how many match what I am looking at right now. The first is the one a counter is for, and it is also what the same switch counts in Pro, so a board keeps its meaning across an upgrade. The column matches Pro's name, type and default, so the setting survives that upgrade rather than being dropped on the way. --- .../PreferencesPane/Others.jsx | 9 ++++++++ .../PreferencesPane/Others.module.scss | 9 ++++++++ client/src/components/lists/List/List.jsx | 17 ++++++++++++++ .../components/lists/List/List.module.scss | 18 +++++++++++++++ client/src/locales/de-DE/core.js | 3 +++ client/src/locales/en-US/core.js | 3 +++ client/src/models/Board.js | 1 + client/src/selectors/lists.js | 23 +++++++++++++++++++ server/api/controllers/boards/update.js | 9 ++++++++ server/api/models/Board.js | 12 ++++++++++ ...8000000_add_show_card_counter_to_boards.js | 19 +++++++++++++++ 11 files changed, 123 insertions(+) create mode 100644 server/db/migrations/20260918000000_add_show_card_counter_to_boards.js diff --git a/client/src/components/boards/BoardSettingsModal/PreferencesPane/Others.jsx b/client/src/components/boards/BoardSettingsModal/PreferencesPane/Others.jsx index fa7506e7..40465833 100644 --- a/client/src/components/boards/BoardSettingsModal/PreferencesPane/Others.jsx +++ b/client/src/components/boards/BoardSettingsModal/PreferencesPane/Others.jsx @@ -43,6 +43,15 @@ const Others = React.memo(() => { className={styles.radio} onChange={handleChange} /> + +

{t('common.showCardCounterHint')}

{ [], ); + const selectCardCountByListId = useMemo(() => selectors.makeSelectCardCountByListId(), []); + const clipboard = useSelector(selectors.selectClipboard); const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser); const list = useSelector((state) => selectListById(state, id)); const cardIds = useSelector((state) => selectFilteredCardIdsByListId(state, id)); + // Shown on the add-card button when the board asks for it. Counted over every + // card the list holds, not the ones the filter leaves standing: a filter + // hides cards, it does not remove them, and a number that fell as the filter + // narrowed would say the list had emptied. + const showCardCounter = useSelector( + (state) => selectors.selectCurrentBoard(state).showCardCounter, + ); + + const cardCount = useSelector((state) => selectCardCountByListId(state, id)); + const { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector( (state) => { const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out? @@ -148,6 +160,10 @@ const List = React.memo(({ id, index }) => { cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight; }, [scrollBottomState]); + const cardCountNode = showCardCounter && cardCount !== null && ( + {cardCount} + ); + const ActionsPopup = usePopup(ActionsStep); const ArchiveCardsPopup = usePopup(ArchiveCardsStep); @@ -274,6 +290,7 @@ const List = React.memo(({ id, index }) => { )} onClick={handleAddCardClick} > + {cardCountNode} {cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')} diff --git a/client/src/components/lists/List/List.module.scss b/client/src/components/lists/List/List.module.scss index d4a4b767..5fb488a5 100644 --- a/client/src/components/lists/List/List.module.scss +++ b/client/src/components/lists/List/List.module.scss @@ -33,6 +33,24 @@ } } + .addCardButtonCount { + background: #17394d; + border-radius: 10px; + color: #dfe3e6; + display: inline-block; + font-size: 11px; + font-weight: 700; + // Same 20px line box as the label beside it, so the two are centred on + // each other by construction rather than by a nudge that only holds at one + // font size. + line-height: 20px; + margin-right: 6px; + min-width: 20px; + padding: 0 6px; + text-align: center; + vertical-align: top; + } + .addCardButtonIcon { height: 20px; padding: 0.64px; diff --git a/client/src/locales/de-DE/core.js b/client/src/locales/de-DE/core.js index 9b5498c6..a92b8e08 100644 --- a/client/src/locales/de-DE/core.js +++ b/client/src/locales/de-DE/core.js @@ -398,6 +398,9 @@ export default { settings: 'Einstellungen', shared: 'Geteilt', sharedWithMe_title: 'Mit mir geteilt', + showCardCounter: 'Kartenzähler in Listen anzeigen', + showCardCounterHint: + 'Zeigt die Anzahl der Karten einer Liste auf ihrer Schaltfläche zum Hinzufügen einer Karte an. Gezählt werden alle Karten der Liste, nicht nur die von einem Filter übrig gelassenen.', showOnFrontOfCard: 'Auf der Vorderseite der Karte anzeigen', smtp: 'SMTP', sortList_title: 'Liste sortieren', diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js index 09433d58..709ba1ce 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -375,6 +375,9 @@ export default { settings: 'Settings', shared: 'Shared', sharedWithMe_title: 'Shared With Me', + showCardCounter: 'Show card counter in lists', + showCardCounterHint: + 'Adds the number of cards a list holds to its add-card button. The count covers every card in the list, not only the ones a filter leaves visible.', showOnFrontOfCard: 'Show on front of card', smtp: 'SMTP', sortList_title: 'Sort List', diff --git a/client/src/models/Board.js b/client/src/models/Board.js index 0acea83b..0bb55b5e 100755 --- a/client/src/models/Board.js +++ b/client/src/models/Board.js @@ -36,6 +36,7 @@ export default class extends BaseModel { defaultCardType: attr(), limitCardTypesToDefaultOne: attr(), alwaysDisplayCardCreator: attr(), + showCardCounter: attr(), displayCardAges: attr(), expandTaskListsByDefault: attr(), context: attr(), diff --git a/client/src/selectors/lists.js b/client/src/selectors/lists.js index 098259bf..acd605cd 100644 --- a/client/src/selectors/lists.js +++ b/client/src/selectors/lists.js @@ -65,6 +65,27 @@ export const makeSelectFilteredCardIdsByListId = () => export const selectFilteredCardIdsByListId = makeSelectFilteredCardIdsByListId(); +// How many cards the list holds. +// +// Unfiltered on purpose: a filter hides cards, it does not remove them, and a +// count that fell as the filter narrowed would say the list had emptied. +export const makeSelectCardCountByListId = () => + createSelector( + orm, + (_, id) => id, + ({ List }, id) => { + const listModel = List.withId(id); + + if (!listModel) { + return null; + } + + return listModel.getCardsModelArray().length; + }, + ); + +export const selectCardCountByListId = makeSelectCardCountByListId(); + export const selectIsListWithIdAvailableForCurrentUser = createSelector( orm, (_, id) => id, @@ -171,6 +192,8 @@ export default { selectCardIdsByListId, makeSelectFilteredCardIdsByListId, selectFilteredCardIdsByListId, + makeSelectCardCountByListId, + selectCardCountByListId, selectIsListWithIdAvailableForCurrentUser, selectCurrentListId, selectCurrentList, diff --git a/server/api/controllers/boards/update.js b/server/api/controllers/boards/update.js index ab0a5176..8744f304 100755 --- a/server/api/controllers/boards/update.js +++ b/server/api/controllers/boards/update.js @@ -55,6 +55,10 @@ * type: boolean * description: Whether to always display card creators * example: false + * showCardCounter: + * type: boolean + * description: Whether a list's add-card button shows how many cards it holds + * example: false * displayCardAges: * type: boolean * description: Whether to display card ages @@ -124,6 +128,9 @@ module.exports = { alwaysDisplayCardCreator: { type: 'boolean', }, + showCardCounter: { + type: 'boolean', + }, displayCardAges: { type: 'boolean', }, @@ -167,6 +174,7 @@ module.exports = { 'defaultCardType', 'limitCardTypesToDefaultOne', 'alwaysDisplayCardCreator', + 'showCardCounter', 'displayCardAges', 'expandTaskListsByDefault', ); @@ -186,6 +194,7 @@ module.exports = { 'defaultCardType', 'limitCardTypesToDefaultOne', 'alwaysDisplayCardCreator', + 'showCardCounter', 'displayCardAges', 'expandTaskListsByDefault', 'isSubscribed', diff --git a/server/api/models/Board.js b/server/api/models/Board.js index 1d118162..9245ef16 100755 --- a/server/api/models/Board.js +++ b/server/api/models/Board.js @@ -25,6 +25,7 @@ * - defaultCardType * - limitCardTypesToDefaultOne * - alwaysDisplayCardCreator + * - showCardCounter * - displayCardAges * - expandTaskListsByDefault * - createdAt @@ -68,6 +69,11 @@ * default: false * description: Whether to always display the card creator * example: false + * showCardCounter: + * type: boolean + * default: false + * description: Whether a list's add-card button shows how many cards it holds + * example: false * displayCardAges: * type: boolean * default: false @@ -143,6 +149,12 @@ module.exports = { defaultsTo: false, columnName: 'always_display_card_creator', }, + // Whether a list's add-card button carries the number of cards it holds. + showCardCounter: { + type: 'boolean', + defaultsTo: false, + columnName: 'show_card_counter', + }, displayCardAges: { type: 'boolean', defaultsTo: false, diff --git a/server/db/migrations/20260918000000_add_show_card_counter_to_boards.js b/server/db/migrations/20260918000000_add_show_card_counter_to_boards.js new file mode 100644 index 00000000..5abc8570 --- /dev/null +++ b/server/db/migrations/20260918000000_add_show_card_counter_to_boards.js @@ -0,0 +1,19 @@ +/*! + * Copyright (c) 2026 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +// Whether a list says how many cards it holds, on its add-card button. Off by +// default: a count is useful to a board that is being kept to a size and noise +// to one that is not. +module.exports.up = async (knex) => { + await knex.schema.alterTable('board', (table) => { + table.boolean('show_card_counter').notNullable().defaultTo(false); + }); +}; + +module.exports.down = async (knex) => { + await knex.schema.alterTable('board', (table) => { + table.dropColumn('show_card_counter'); + }); +};