mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Add Kanban WIP limits
This commit is contained in:
parent
3b426b95fe
commit
02abf41e4a
5 changed files with 177 additions and 4 deletions
|
|
@ -60,6 +60,7 @@ Example:
|
|||
- ([#1805](https://github.com/callumalpass/tasknotes/issues/1805)) Added an ICS export option to omit completed tasks from generated calendar files. Thanks to @bepolymathe for suggesting this.
|
||||
- ([#1818](https://github.com/callumalpass/tasknotes/issues/1818)) Added an auto-height mode for TaskNotes Calendar Bases views so embedded agenda/list sections can size to their content instead of forcing an inner scroller. Thanks to @martin-forge for suggesting this.
|
||||
- ([#1201](https://github.com/callumalpass/tasknotes/issues/1201)) Added a Calendar Bases toggle to hide the hourly breakdown in week, custom-days, and day views while keeping the all-day planning row visible. Thanks to @PacoTaco2 for suggesting this.
|
||||
- ([#1176](https://github.com/callumalpass/tasknotes/issues/1176)) Added advanced Kanban WIP limits through `wipLimits`, so column headers can show `(current/limit)` counts and highlight exceeded limits. Thanks to @williamcheuk03 for suggesting this.
|
||||
- ([#1732](https://github.com/callumalpass/tasknotes/issues/1732), [#1835](https://github.com/callumalpass/tasknotes/issues/1835)) Added hotkeyable commands to edit the current task, add a project to the current task, and add an existing task as a subtask of the current note. Thanks to @prepare4robots for requesting this and @ubidev for the follow-up suggestion.
|
||||
- ([#1655](https://github.com/callumalpass/tasknotes/issues/1655)) Added live elapsed time to the optional active time tracking status bar item. Thanks to @connradolisboa for suggesting this.
|
||||
- ([#1622](https://github.com/callumalpass/tasknotes/issues/1622)) Allowed multiple comma-separated default reminder offsets for timed Google Calendar task exports. Thanks to @solidabstract for suggesting this.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ Access these options through the Bases view settings panel:
|
|||
- **Column Width**: Controls the width of columns in pixels. Range: 200-500px. Default: 280px
|
||||
- **Hide Empty Columns**: When enabled, columns containing no tasks are hidden from the view
|
||||
- **Pinned Columns**: Optional comma-separated list of column values that should stay visible even when empty. This is useful with **Hide Empty Columns** when each board needs a small stable subset of shared statuses or categories
|
||||
- **WIP Limits**: Advanced JSON configuration for showing per-column work-in-progress limits in column headers
|
||||
- **Hide Empty Swimlanes**: When enabled, ordered swimlanes with no tasks are hidden from the view
|
||||
- **Show items in multiple columns**: When enabled (default), tasks with multiple values in list properties (contexts, tags, projects) appear in each individual column. For example, a task with `contexts: [work, call]` appears in both the "work" and "call" columns. When disabled, tasks appear in a single combined column (e.g., "work, call")
|
||||
- **Column Order**: Managed automatically when dragging column headers. Stores custom column ordering
|
||||
|
|
@ -64,6 +65,15 @@ config:
|
|||
|
||||
Listed swimlane values render first in the configured order. Values not listed in `swimLaneOrder` render below them using the default order for status and priority swimlanes, or alphabetically for other properties. If `hideEmptySwimLanes` is disabled, listed values can remain visible even when no matching tasks are currently present.
|
||||
|
||||
To show WIP limits in column headers, set the advanced `wipLimits` option to a JSON object keyed by column value:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
wipLimits: '{"in-progress":5,"review":2}'
|
||||
```
|
||||
|
||||
Columns with limits display counts as `(current/limit)`. When the current count is above the configured limit, the count uses the error color. In swimlane boards, the column header count is the total for that column across all swimlanes.
|
||||
|
||||
## Task Cards
|
||||
|
||||
Each task card displays information based on the visible properties configured in the Bases view. Standard task information includes title, priority, due date, and scheduled date.
|
||||
|
|
@ -139,6 +149,7 @@ views:
|
|||
columnWidth: 300
|
||||
hideEmptyColumns: true
|
||||
pinnedColumns: to-do, in-progress, done
|
||||
wipLimits: '{"in-progress":5}'
|
||||
---
|
||||
```
|
||||
|
||||
|
|
@ -148,6 +159,7 @@ This configuration creates a Kanban board with:
|
|||
- Priority swimlanes pinned in high, normal, low order
|
||||
- 300px column width
|
||||
- Empty unpinned columns hidden, while the to-do, in-progress, and done columns stay visible as drop targets
|
||||
- A WIP limit of 5 tasks on the in-progress column
|
||||
|
||||
## Filtering and Sorting
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,74 @@ function normalizeOrderConfig(value: unknown): Record<string, string[]> {
|
|||
return result;
|
||||
}
|
||||
|
||||
function normalizeWipLimitValue(value: unknown): number | null {
|
||||
const numericValue =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim()
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(numericValue);
|
||||
}
|
||||
|
||||
export function normalizeKanbanWipLimitsConfig(value: unknown): Record<string, number> {
|
||||
if (value === null || value === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
for (const [columnKey, rawLimit] of Object.entries(parsed)) {
|
||||
const normalizedKey = stringifyUnknown(columnKey).trim();
|
||||
const limit = normalizeWipLimitValue(rawLimit);
|
||||
if (normalizedKey && limit !== null) {
|
||||
result[normalizedKey] = limit;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatKanbanColumnCount(
|
||||
taskCount: number,
|
||||
wipLimit: number | null | undefined
|
||||
): { text: string; isExceeded: boolean } {
|
||||
const normalizedLimit = normalizeWipLimitValue(wipLimit);
|
||||
if (normalizedLimit === null) {
|
||||
return {
|
||||
text: ` (${taskCount})`,
|
||||
isExceeded: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: ` (${taskCount}/${normalizedLimit})`,
|
||||
isExceeded: taskCount > normalizedLimit,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePinnedColumnConfig(value: unknown): string[] {
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
|
|
@ -321,6 +389,7 @@ export class KanbanView extends BasesViewBase {
|
|||
private consolidateStatusIcon = false; // Show status icon in header only when grouped by status
|
||||
private columnOrders: Record<string, string[]> = {};
|
||||
private pinnedColumns: string[] = [];
|
||||
private wipLimits: Record<string, number> = {};
|
||||
private swimLaneOrders: Record<string, string[]> = {};
|
||||
private hideEmptySwimLanes = false;
|
||||
private configLoaded = false; // Track if we've successfully loaded config
|
||||
|
|
@ -425,6 +494,7 @@ export class KanbanView extends BasesViewBase {
|
|||
// Read column orders
|
||||
this.columnOrders = normalizeOrderConfig(this.config.get("columnOrder"));
|
||||
this.pinnedColumns = normalizePinnedColumnConfig(this.config.get("pinnedColumns"));
|
||||
this.wipLimits = normalizeKanbanWipLimitsConfig(this.config.get("wipLimits"));
|
||||
|
||||
// Read swimlane orders. Support both the public singular key and the
|
||||
// originally proposed plural key for manually-authored Bases YAML.
|
||||
|
|
@ -1304,6 +1374,7 @@ export class KanbanView extends BasesViewBase {
|
|||
headerRow.createEl("div", { cls: "kanban-view__swimlane-label" });
|
||||
|
||||
// Column headers
|
||||
const columnTaskCounts = this.getColumnTaskCounts(swimLanes, columnKeys);
|
||||
for (const columnKey of columnKeys) {
|
||||
const headerCell = headerRow.createEl("div", {
|
||||
cls: "kanban-view__column-header-cell",
|
||||
|
|
@ -1327,6 +1398,7 @@ export class KanbanView extends BasesViewBase {
|
|||
|
||||
const titleContainer = headerCell.createSpan({ cls: "kanban-view__column-title" });
|
||||
this.renderGroupTitleWrapper(titleContainer, columnKey, false, true);
|
||||
this.renderColumnCount(headerCell, columnKey, columnTaskCounts.get(columnKey) ?? 0);
|
||||
|
||||
// Setup column header drag handlers for swimlane mode
|
||||
this.setupColumnHeaderDragHandlers(headerCell);
|
||||
|
|
@ -1456,10 +1528,7 @@ export class KanbanView extends BasesViewBase {
|
|||
const titleContainer = header.createSpan({ cls: "kanban-view__column-title" });
|
||||
this.renderGroupTitleWrapper(titleContainer, groupKey, false, true);
|
||||
|
||||
header.createSpan({
|
||||
cls: "kanban-view__column-count",
|
||||
text: ` (${tasks.length})`,
|
||||
});
|
||||
this.renderColumnCount(header, groupKey, tasks.length);
|
||||
|
||||
// Setup column header drag handlers
|
||||
this.setupColumnHeaderDragHandlers(header);
|
||||
|
|
@ -1490,6 +1559,36 @@ export class KanbanView extends BasesViewBase {
|
|||
return column;
|
||||
}
|
||||
|
||||
private getColumnTaskCounts(
|
||||
swimLanes: Map<string, Map<string, TaskInfo[]>>,
|
||||
columnKeys: string[]
|
||||
): Map<string, number> {
|
||||
const counts = new Map(columnKeys.map((columnKey) => [columnKey, 0]));
|
||||
|
||||
for (const columns of swimLanes.values()) {
|
||||
for (const columnKey of columnKeys) {
|
||||
counts.set(
|
||||
columnKey,
|
||||
(counts.get(columnKey) ?? 0) + (columns.get(columnKey)?.length ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
private renderColumnCount(container: HTMLElement, groupKey: string, taskCount: number): void {
|
||||
const count = formatKanbanColumnCount(taskCount, this.wipLimits[groupKey]);
|
||||
const countEl = container.createSpan({
|
||||
cls: "kanban-view__column-count",
|
||||
text: count.text,
|
||||
});
|
||||
|
||||
if (count.isExceeded) {
|
||||
countEl.addClass("kanban-view__column-count--exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
private createAddTaskButton(
|
||||
container: HTMLElement,
|
||||
groupByPropertyId: string | null,
|
||||
|
|
|
|||
|
|
@ -344,6 +344,11 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .kanban-view__column-count--exceeded {
|
||||
color: var(--tn-color-error);
|
||||
font-weight: var(--tn-font-weight-semibold);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
KANBAN TASK CARDS
|
||||
================================================ */
|
||||
|
|
|
|||
56
tests/unit/issues/issue-1176-kanban-wip-limits.test.ts
Normal file
56
tests/unit/issues/issue-1176-kanban-wip-limits.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Issue #1176: Kanban columns can show WIP limits and mark exceeded columns.
|
||||
*
|
||||
* @see https://github.com/callumalpass/tasknotes/issues/1176
|
||||
*/
|
||||
|
||||
import {
|
||||
formatKanbanColumnCount,
|
||||
normalizeKanbanWipLimitsConfig,
|
||||
} from "../../../src/bases/KanbanView";
|
||||
|
||||
jest.mock("obsidian");
|
||||
|
||||
describe("Issue #1176: Kanban WIP limits", () => {
|
||||
it("normalizes WIP limit config from JSON strings and objects", () => {
|
||||
expect(
|
||||
normalizeKanbanWipLimitsConfig('{"todo":5,"in-progress":"3","done":0,"bad":"x"}')
|
||||
).toEqual({
|
||||
todo: 5,
|
||||
"in-progress": 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeKanbanWipLimitsConfig({
|
||||
Review: 2.8,
|
||||
Blocked: 1,
|
||||
Ignored: -1,
|
||||
})
|
||||
).toEqual({
|
||||
Review: 2,
|
||||
Blocked: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats plain counts when no positive WIP limit is configured", () => {
|
||||
expect(formatKanbanColumnCount(4, undefined)).toEqual({
|
||||
text: " (4)",
|
||||
isExceeded: false,
|
||||
});
|
||||
expect(formatKanbanColumnCount(4, 0)).toEqual({
|
||||
text: " (4)",
|
||||
isExceeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats WIP counts and marks exceeded limits", () => {
|
||||
expect(formatKanbanColumnCount(3, 5)).toEqual({
|
||||
text: " (3/5)",
|
||||
isExceeded: false,
|
||||
});
|
||||
expect(formatKanbanColumnCount(6, 5)).toEqual({
|
||||
text: " (6/5)",
|
||||
isExceeded: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue