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.
This commit is contained in:
Daniel Hiller
2026-09-17 03:51:40 +02:00
parent ca035d670e
commit b581a97c2c
11 changed files with 123 additions and 0 deletions
@@ -43,6 +43,15 @@ const Others = React.memo(() => {
className={styles.radio} className={styles.radio}
onChange={handleChange} onChange={handleChange}
/> />
<Radio
toggle
name="showCardCounter"
checked={board.showCardCounter}
label={t('common.showCardCounter')}
className={styles.radio}
onChange={handleChange}
/>
<p className={styles.hint}>{t('common.showCardCounterHint')}</p>
<Radio <Radio
toggle toggle
name="displayCardAges" name="displayCardAges"
@@ -4,6 +4,15 @@
*/ */
:global(#app) { :global(#app) {
// Sits under the toggle it belongs to: pulled up out of that toggle's 16px
// gap so only a few pixels separate the two, and carrying the full 16px on
// its own underside so the next toggle keeps its distance.
.hint {
color: #666666;
margin-bottom: 16px;
margin-top: -12px;
}
.radio { .radio {
margin-bottom: 16px; margin-bottom: 16px;
width: 100%; width: 100%;
+17
View File
@@ -49,12 +49,24 @@ const List = React.memo(({ id, index }) => {
[], [],
); );
const selectCardCountByListId = useMemo(() => selectors.makeSelectCardCountByListId(), []);
const clipboard = useSelector(selectors.selectClipboard); const clipboard = useSelector(selectors.selectClipboard);
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser); const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
const list = useSelector((state) => selectListById(state, id)); const list = useSelector((state) => selectListById(state, id));
const cardIds = useSelector((state) => selectFilteredCardIdsByListId(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( const { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector(
(state) => { (state) => {
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out? const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
@@ -148,6 +160,10 @@ const List = React.memo(({ id, index }) => {
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight; cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
}, [scrollBottomState]); }, [scrollBottomState]);
const cardCountNode = showCardCounter && cardCount !== null && (
<span className={styles.addCardButtonCount}>{cardCount}</span>
);
const ActionsPopup = usePopup(ActionsStep); const ActionsPopup = usePopup(ActionsStep);
const ArchiveCardsPopup = usePopup(ArchiveCardsStep); const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
@@ -274,6 +290,7 @@ const List = React.memo(({ id, index }) => {
)} )}
onClick={handleAddCardClick} onClick={handleAddCardClick}
> >
{cardCountNode}
<PlusMathIcon className={styles.addCardButtonIcon} /> <PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}> <span className={styles.addCardButtonText}>
{cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')} {cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')}
@@ -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 { .addCardButtonIcon {
height: 20px; height: 20px;
padding: 0.64px; padding: 0.64px;
+3
View File
@@ -398,6 +398,9 @@ export default {
settings: 'Einstellungen', settings: 'Einstellungen',
shared: 'Geteilt', shared: 'Geteilt',
sharedWithMe_title: 'Mit mir 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', showOnFrontOfCard: 'Auf der Vorderseite der Karte anzeigen',
smtp: 'SMTP', smtp: 'SMTP',
sortList_title: 'Liste sortieren', sortList_title: 'Liste sortieren',
+3
View File
@@ -375,6 +375,9 @@ export default {
settings: 'Settings', settings: 'Settings',
shared: 'Shared', shared: 'Shared',
sharedWithMe_title: 'Shared With Me', 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', showOnFrontOfCard: 'Show on front of card',
smtp: 'SMTP', smtp: 'SMTP',
sortList_title: 'Sort List', sortList_title: 'Sort List',
+1
View File
@@ -36,6 +36,7 @@ export default class extends BaseModel {
defaultCardType: attr(), defaultCardType: attr(),
limitCardTypesToDefaultOne: attr(), limitCardTypesToDefaultOne: attr(),
alwaysDisplayCardCreator: attr(), alwaysDisplayCardCreator: attr(),
showCardCounter: attr(),
displayCardAges: attr(), displayCardAges: attr(),
expandTaskListsByDefault: attr(), expandTaskListsByDefault: attr(),
context: attr(), context: attr(),
+23
View File
@@ -65,6 +65,27 @@ export const makeSelectFilteredCardIdsByListId = () =>
export const selectFilteredCardIdsByListId = 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( export const selectIsListWithIdAvailableForCurrentUser = createSelector(
orm, orm,
(_, id) => id, (_, id) => id,
@@ -171,6 +192,8 @@ export default {
selectCardIdsByListId, selectCardIdsByListId,
makeSelectFilteredCardIdsByListId, makeSelectFilteredCardIdsByListId,
selectFilteredCardIdsByListId, selectFilteredCardIdsByListId,
makeSelectCardCountByListId,
selectCardCountByListId,
selectIsListWithIdAvailableForCurrentUser, selectIsListWithIdAvailableForCurrentUser,
selectCurrentListId, selectCurrentListId,
selectCurrentList, selectCurrentList,
+9
View File
@@ -55,6 +55,10 @@
* type: boolean * type: boolean
* description: Whether to always display card creators * description: Whether to always display card creators
* example: false * example: false
* showCardCounter:
* type: boolean
* description: Whether a list's add-card button shows how many cards it holds
* example: false
* displayCardAges: * displayCardAges:
* type: boolean * type: boolean
* description: Whether to display card ages * description: Whether to display card ages
@@ -124,6 +128,9 @@ module.exports = {
alwaysDisplayCardCreator: { alwaysDisplayCardCreator: {
type: 'boolean', type: 'boolean',
}, },
showCardCounter: {
type: 'boolean',
},
displayCardAges: { displayCardAges: {
type: 'boolean', type: 'boolean',
}, },
@@ -167,6 +174,7 @@ module.exports = {
'defaultCardType', 'defaultCardType',
'limitCardTypesToDefaultOne', 'limitCardTypesToDefaultOne',
'alwaysDisplayCardCreator', 'alwaysDisplayCardCreator',
'showCardCounter',
'displayCardAges', 'displayCardAges',
'expandTaskListsByDefault', 'expandTaskListsByDefault',
); );
@@ -186,6 +194,7 @@ module.exports = {
'defaultCardType', 'defaultCardType',
'limitCardTypesToDefaultOne', 'limitCardTypesToDefaultOne',
'alwaysDisplayCardCreator', 'alwaysDisplayCardCreator',
'showCardCounter',
'displayCardAges', 'displayCardAges',
'expandTaskListsByDefault', 'expandTaskListsByDefault',
'isSubscribed', 'isSubscribed',
+12
View File
@@ -25,6 +25,7 @@
* - defaultCardType * - defaultCardType
* - limitCardTypesToDefaultOne * - limitCardTypesToDefaultOne
* - alwaysDisplayCardCreator * - alwaysDisplayCardCreator
* - showCardCounter
* - displayCardAges * - displayCardAges
* - expandTaskListsByDefault * - expandTaskListsByDefault
* - createdAt * - createdAt
@@ -68,6 +69,11 @@
* default: false * default: false
* description: Whether to always display the card creator * description: Whether to always display the card creator
* example: false * example: false
* showCardCounter:
* type: boolean
* default: false
* description: Whether a list's add-card button shows how many cards it holds
* example: false
* displayCardAges: * displayCardAges:
* type: boolean * type: boolean
* default: false * default: false
@@ -143,6 +149,12 @@ module.exports = {
defaultsTo: false, defaultsTo: false,
columnName: 'always_display_card_creator', 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: { displayCardAges: {
type: 'boolean', type: 'boolean',
defaultsTo: false, defaultsTo: false,
@@ -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');
});
};