Files
task-stream/libs/web/tracker/application/src/lib/stores/board.reducers.ts
Josep Piguillem 298383f19f feat: saved board views and richer board filters
The board now filters by text, type, priority and unassigned tickets as
well. Any combination can be saved as a named view, private or shared with
the project, and opened again with one click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:38 +02:00

110 lines
3.1 KiB
TypeScript

import {
BoardColumn,
BoardFilter,
IssueSummary,
} from '@task-stream/web/tracker/domain';
/** Pure board transformations (unit tested). */
export function findIssue(
columns: BoardColumn[],
issueId: string,
): IssueSummary | undefined {
for (const column of columns) {
const issue = column.issues.find((i) => i.id === issueId);
if (issue) return issue;
}
return undefined;
}
export function matchesFilter(
issue: IssueSummary,
filter: BoardFilter | null,
): boolean {
if (!filter) return true;
if (filter.unassigned) {
if (issue.assignee) return false;
} else if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
return false;
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
if (filter.area && issue.area !== filter.area) return false;
if (filter.types?.length && !filter.types.includes(issue.type)) return false;
if (filter.priorities?.length && !filter.priorities.includes(issue.priority))
return false;
if (
filter.labels?.length &&
!filter.labels.every((l) => issue.labels.includes(l.toLowerCase()))
)
return false;
// Cards carry no description: a change that matched only by it drops off
// until the next load, which is the safe side.
const text = filter.search?.trim().toLowerCase();
if (
text &&
!issue.key.toLowerCase().includes(text) &&
!issue.title.toLowerCase().includes(text)
)
return false;
return true;
}
/** `issues` (in backlog order) with `issue` at its place by rank. */
export function insertByRank(
issues: IssueSummary[],
issue: IssueSummary,
): IssueSummary[] {
const at = issues.findIndex((i) => i.rank > issue.rank);
return at === -1
? [...issues, issue]
: [...issues.slice(0, at), issue, ...issues.slice(at)];
}
/** Removes the issue wherever it is and puts `next` (if any) at its place by rank in its status column. */
export function placeIssue(
columns: BoardColumn[],
issueId: string,
next: IssueSummary | null,
): BoardColumn[] {
return columns.map((column) => {
const had = column.issues.some((i) => i.id === issueId);
let issues = had
? column.issues.filter((i) => i.id !== issueId)
: column.issues;
let total = column.total - (had ? 1 : 0);
if (next && next.status === column.status) {
issues = insertByRank(issues, next);
total += 1;
}
return had || issues !== column.issues
? { ...column, issues, total }
: column;
});
}
/** Optimistic move. */
export function moveIssue(
columns: BoardColumn[],
issueId: string,
to: string,
): BoardColumn[] {
const issue = findIssue(columns, issueId);
return issue
? placeIssue(columns, issueId, { ...issue, status: to })
: columns;
}
/** Merges a realtime change, ignoring stale versions and issues outside the filter. */
export function applyIssueChange(
columns: BoardColumn[],
issue: IssueSummary,
filter: BoardFilter | null,
): BoardColumn[] {
const current = findIssue(columns, issue.id);
if (current && current.version > issue.version) return columns;
return placeIssue(
columns,
issue.id,
matchesFilter(issue, filter) ? issue : null,
);
}