feat: work-in-progress limits and swimlanes on the board

Each status can carry a WIP limit in the project settings; the board shows
it next to the count, marks a column that goes over it and says so when a
card is dropped there. The board can also be grouped in rows by person,
epic, priority or type, remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:09:21 +02:00
parent 6aec774c92
commit 09bd8c19a3
23 changed files with 563 additions and 74 deletions

View File

@@ -99,6 +99,23 @@ describe('Project settings', () => {
);
});
it('keeps work-in-progress limits, from 1 to 999', () => {
const project = newProject();
const limited = defaultProjectSettings();
limited.statuses[2].wipLimit = 3;
project.updateSettings('owner', limited);
expect(project.workflow.wipLimit(limited.statuses[2].id)).toBe(3);
expect(project.workflow.wipLimit('TODO')).toBeNull();
// Saving the same again is no change.
const before = project.getUncommittedEvents().length;
project.updateSettings('owner', structuredClone(limited));
expect(project.getUncommittedEvents()).toHaveLength(before);
const zero = defaultProjectSettings();
zero.statuses[2].wipLimit = 0;
expect(() => project.updateSettings('owner', zero)).toThrow(/WIP/);
});
it('rejects settings that would break the board', () => {
const project = newProject();
const noStart = defaultProjectSettings();

View File

@@ -61,6 +61,11 @@ export interface WorkflowStatus {
requiresReason: boolean;
/** Archived: no new tickets or transitions into it; existing ones keep it. */
archived: boolean;
/**
* Work-in-progress limit: how many tickets the column should hold at most
* (null: no limit). A warning, not a lock: the board flags a full column.
*/
wipLimit?: number | null;
}
export interface IssueTypeOption {
@@ -193,6 +198,11 @@ export class ProjectWorkflow {
return this.settings.statuses.find((s) => s.id === id);
}
/** Most tickets the status should hold (null: no limit). */
wipLimit(id: string): number | null {
return this.status(id)?.wipLimit ?? null;
}
/** Name to show, also for ids no longer configured. */
statusName(id: string): string {
return (
@@ -287,6 +297,8 @@ export function validateProjectSettings(
color: s.color,
requiresReason: !!s.requiresReason,
archived: !!s.archived,
// Only when set: settings saved unchanged must compare equal.
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
}));
const issueTypes = next.issueTypes.map((t) => ({
id: normId(t.id),
@@ -326,6 +338,11 @@ export function validateProjectSettings(
SETTING_COLORS.includes(s.color),
`Color no válido en el estado ${s.name}`,
);
assertThat(
s.wipLimit === undefined ||
(Number.isInteger(s.wipLimit) && s.wipLimit >= 1 && s.wipLimit <= 999),
`El límite WIP del estado ${s.name} tiene que ser un número entre 1 y 999`,
);
}
for (const t of issueTypes) {
assertThat(

View File

@@ -14,6 +14,7 @@ export interface SettingsPayload {
color: string;
requiresReason: boolean;
archived: boolean;
wipLimit?: number | null;
}[];
transitions: { from: string; to: string[] }[];
issueTypes: {

View File

@@ -425,6 +425,15 @@ export class WorkflowStatusInput {
@Field() @IsString() color!: string;
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
@Field(() => Int, {
nullable: true,
description: 'Work-in-progress limit (null: none)',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(999)
wipLimit?: number | null;
}
@InputType()

View File

@@ -33,6 +33,12 @@ export class WorkflowStatusGql {
requiresReason!: boolean;
@Field({ description: 'No new tickets or transitions into it' })
archived!: boolean;
@Field(() => Int, {
nullable: true,
description:
'Work-in-progress limit: most tickets the column should hold (a warning, not a lock)',
})
wipLimit?: number | null;
}
@ObjectType('StatusTransitions')

View File

@@ -142,6 +142,18 @@ export class WorkflowStatusBodyDto {
@IsOptional()
@IsBoolean()
archived = false;
@ApiPropertyOptional({
type: Number,
nullable: true,
minimum: 1,
maximum: 999,
description: 'Work-in-progress limit (null: none)',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(999)
wipLimit?: number | null;
}
export class StatusTransitionsBodyDto {

View File

@@ -39,6 +39,12 @@ export class WorkflowStatusDto {
requiresReason!: boolean;
@ApiProperty({ description: 'No new tickets or transitions into it' })
archived!: boolean;
@ApiProperty({
type: Number,
nullable: true,
description: 'Work-in-progress limit (a warning, not a lock)',
})
wipLimit!: number | null;
}
export class StatusTransitionsDto {

View File

@@ -7,3 +7,4 @@ export * from './lib/models/mentions';
export * from './lib/models/hierarchy';
export * from './lib/models/views';
export * from './lib/models/estimation';
export * from './lib/models/swimlanes';

View File

@@ -0,0 +1,51 @@
import { IssuePriority } from './enums';
import { BoardColumn, IssueSummary } from './models';
import { overWip, swimlanes } from './swimlanes';
const card = (id: string, assignee: string | null, parent: string | null) =>
({
id,
assignee: assignee ? { id: assignee, name: assignee } : null,
parent: parent ? { id: parent, key: parent, title: parent } : null,
priority: IssuePriority.MEDIUM,
type: 'TASK',
}) as IssueSummary;
const board: BoardColumn[] = [
{
status: 'TODO',
total: 3,
issues: [
card('a', 'ana', 'e1'),
card('b', null, null),
card('c', 'bob', 'e1'),
],
},
{ status: 'DONE', total: 1, issues: [card('d', 'ana', null)] },
];
describe('swimlanes', () => {
it('splits the board by person, nobody last', () => {
const lanes = swimlanes(board, 'assignee');
expect(lanes.map((l) => [l.key, l.count])).toEqual([
['ana', 2],
['bob', 1],
['', 1],
]);
expect(lanes[0].columns.map((c) => c.issues.map((i) => i.id))).toEqual([
['a'],
['d'],
]);
});
it('groups by epic and keeps one row when not grouping', () => {
expect(swimlanes(board, 'epic').map((l) => l.key)).toEqual(['e1', '']);
expect(swimlanes(board, 'none')).toHaveLength(1);
});
it('flags a column over its WIP limit', () => {
expect(overWip(4, 3)).toBe(true);
expect(overWip(3, 3)).toBe(false);
expect(overWip(9, null)).toBe(false);
});
});

View File

@@ -0,0 +1,72 @@
import { BoardColumn, IssueSummary } from './models';
/** How the board can be split in rows. */
export type SwimlaneMode = 'none' | 'assignee' | 'priority' | 'type' | 'epic';
export interface Swimlane {
/** Stable key of the row (`''` for "nobody", "no epic"...). */
key: string;
/** Same columns as the board, each with only this row's cards. */
columns: BoardColumn[];
count: number;
}
/** Key of the row a card belongs to. */
export function laneKeyOf(issue: IssueSummary, mode: SwimlaneMode): string {
switch (mode) {
case 'assignee':
return issue.assignee?.id ?? '';
case 'priority':
return issue.priority;
case 'type':
return issue.type;
case 'epic':
return issue.parent?.id ?? '';
default:
return '';
}
}
/**
* The board's columns split in rows. `order` ranks keys (lower first);
* cards keep their order, and the empty key (nobody, no epic) goes last.
*/
export function swimlanes(
columns: BoardColumn[],
mode: SwimlaneMode,
order: (key: string) => number = () => 0,
): Swimlane[] {
if (mode === 'none')
return [
{
key: '',
columns,
count: columns.reduce((sum, c) => sum + c.issues.length, 0),
},
];
const keys = new Set<string>();
for (const column of columns)
for (const issue of column.issues) keys.add(laneKeyOf(issue, mode));
return [...keys]
.sort((a, b) =>
a === '' ? 1 : b === '' ? -1 : order(a) - order(b) || a.localeCompare(b),
)
.map((key) => {
const lane = columns.map((column) => {
const issues = column.issues.filter(
(issue) => laneKeyOf(issue, mode) === key,
);
return { ...column, issues, total: issues.length };
});
return {
key,
columns: lane,
count: lane.reduce((sum, c) => sum + c.issues.length, 0),
};
});
}
/** Whether a column holds more than its limit (`total` counts all of it). */
export function overWip(total: number, limit: number | null): boolean {
return limit !== null && total > limit;
}

View File

@@ -42,6 +42,8 @@ export interface WorkflowStatus {
requiresReason: boolean;
/** No new tickets or transitions into it; existing tickets keep it. */
archived: boolean;
/** Most tickets the column should hold (null/absent: no limit). */
wipLimit?: number | null;
}
export interface StatusTransitions {
@@ -156,6 +158,11 @@ export class Workflow {
return this.settings.areas.filter((a) => !a.archived);
}
/** Most tickets the status should hold (null: no limit). */
wipLimit(id: string): number | null {
return this.status(id).wipLimit ?? null;
}
requiresReason(id: string): boolean {
return this.status(id).requiresReason;
}

View File

@@ -77,6 +77,7 @@ export function toSettingsInput(
color: s.color,
requiresReason: s.requiresReason,
archived: s.archived,
wipLimit: s.wipLimit ?? null,
})),
transitions: settings.transitions.map((t) => ({ from: t.from, to: t.to })),
issueTypes: settings.issueTypes.map((t) => ({

View File

@@ -28,6 +28,7 @@ fragment ProjectFields on Project {
color
requiresReason
archived
wipLimit
}
transitions {
from

View File

@@ -239,6 +239,8 @@ export type WorkflowStatusInput = {
id: string;
name: string;
requiresReason?: boolean;
/** Work-in-progress limit (null: none) */
wipLimit?: number | null | undefined;
};
export type UserRefFieldsFragment = { id: string; name: string };
@@ -261,6 +263,7 @@ export type ProjectFieldsFragment = {
color: string;
requiresReason: boolean;
archived: boolean;
wipLimit: number | null;
}>;
transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{
@@ -892,6 +895,7 @@ export type MyProjectsQuery = {
color: string;
requiresReason: boolean;
archived: boolean;
wipLimit: number | null;
}>;
transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{
@@ -929,6 +933,7 @@ export type ProjectQuery = {
color: string;
requiresReason: boolean;
archived: boolean;
wipLimit: number | null;
}>;
transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{
@@ -1076,6 +1081,7 @@ export type PublicProjectsQuery = {
color: string;
requiresReason: boolean;
archived: boolean;
wipLimit: number | null;
}>;
transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{
@@ -1490,6 +1496,7 @@ export type ProjectChangedSubscription = {
color: string;
requiresReason: boolean;
archived: boolean;
wipLimit: number | null;
}>;
transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{
@@ -1530,6 +1537,7 @@ export type MyProjectsChangedSubscription = {
color: string;
requiresReason: boolean;
archived: boolean;
wipLimit: number | null;
}>;
transitions: Array<{ from: string; to: Array<string> }>;
issueTypes: Array<{
@@ -1648,6 +1656,10 @@ export const ProjectFieldsFragmentDoc = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
],
},
},
@@ -5664,6 +5676,10 @@ export const MyProjectsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
],
},
},
@@ -5865,6 +5881,10 @@ export const ProjectDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
],
},
},
@@ -6917,6 +6937,10 @@ export const PublicProjectsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
],
},
},
@@ -9221,6 +9245,10 @@ export const ProjectChangedDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
],
},
},
@@ -9409,6 +9437,10 @@ export const MyProjectsChangedDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'wipLimit' },
},
],
},
},

View File

@@ -147,10 +147,9 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
}
.ts-backlog__row {
display: grid;
grid-template-columns: 2rem auto auto minmax(
0,
1fr
) auto auto 2rem auto auto;
grid-template-columns:
2rem auto auto minmax(0, 1fr)
auto auto 2rem auto auto;
gap: 0.625rem;
align-items: center;
padding: 0.375rem 0.875rem;

View File

@@ -136,3 +136,49 @@
font-weight: 400;
color: var(--ts-muted);
}
.ts-column.is-over {
box-shadow: inset 0 3px 0 var(--ts-c-red, var(--ts-ink));
.ts-column__count {
color: var(--ts-c-red, var(--ts-ink));
font-weight: 700;
}
}
/* Grouped board: heads and rows scroll together. */
.ts-lanes {
overflow-x: auto;
padding-bottom: 0.75rem;
.ts-board {
overflow: visible;
padding-bottom: 0;
}
}
.ts-board--heads {
margin-bottom: 0.5rem;
.ts-column--head {
min-height: 0;
}
}
.ts-lane {
margin-bottom: 1rem;
}
.ts-lane__title {
position: sticky;
left: 0;
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
margin: 0 0 0.5rem;
font-size: 0.875rem;
}
.ts-column--lane .ts-column__list {
min-height: 3.5rem;
}

View File

@@ -16,6 +16,7 @@ import {
untracked,
viewChild,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { form, FormField, maxLength, required } from '@angular/forms/signals';
import {
PiActionButtonComponent,
@@ -30,7 +31,7 @@ import {
type SegmentedControlOption,
} from '@pi/ui';
import { AuthFacade } from '@task-stream/web/identity/application';
import { DrawerService } from '@task-stream/web/shared/ui';
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
import {
BoardFacade,
ProjectFacade,
@@ -42,15 +43,44 @@ import {
boardFilterOf,
IssuePriority,
IssueSummary,
overWip,
POINT_SCALE,
ReleaseStatus,
SavedFilter,
sumPoints,
SwimlaneMode,
swimlanes,
Workflow,
} from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card';
import { options, pointOptions, PRIORITY, statusLook } from '../ui/copy';
import {
options,
pointOptions,
PRIORITY,
PRIORITY_LEVEL,
statusLook,
typeLook,
} from '../ui/copy';
const ALL = '';
const LANE_KEY = 'ts.board.lanes';
/** The grouping last chosen on this browser (a convenience, not a setting). */
function storedLaneMode(): SwimlaneMode {
try {
const stored = localStorage.getItem(LANE_KEY);
return (['assignee', 'epic', 'priority', 'type'] as const).includes(
stored as never,
)
? (stored as SwimlaneMode)
: 'none';
} catch {
return 'none';
}
}
const statusLookType = (workflow: Workflow, id: string) =>
typeLook(workflow, id).label;
/** "Sin asignar" in the person filter. */
const NOBODY = '__nobody__';
@@ -63,6 +93,7 @@ const NOBODY = '__nobody__';
CdkDrag,
FormField,
IssueCardComponent,
NgTemplateOutlet,
PiActionButtonComponent,
PiSelectFieldComponent,
PiSegmentedControlComponent,
@@ -139,6 +170,13 @@ const NOBODY = '__nobody__';
[options]="priorityFilterOptions"
[(value)]="priorityFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Agrupar por"
[options]="laneOptions"
[value]="laneMode()"
(valueChange)="setLaneMode($event)"
/>
<pi-select-field
class="ts-filter"
placeholder="Sprint"
@@ -177,50 +215,110 @@ const NOBODY = '__nobody__';
}
</div>
} @else {
<div class="ts-board" cdkDropListGroup>
@for (column of board.columns(); track column.status) {
<section
class="ts-column"
[attr.data-status]="column.status"
[style.--tone]="statusTone(column.status)"
>
<header class="ts-column__header">
<span class="ts-column__name">{{
statusLabel(column.status)
}}</span>
<span class="ts-column__count"
>{{ column.total }}
@if (pointsOf(column); as points) {
<span class="ts-column__points" title="Puntos estimados"
>· {{ points }} pts</span
>
}
</span>
</header>
<div
class="ts-column__list"
cdkDropList
[id]="column.status"
[cdkDropListData]="column"
[cdkDropListDisabled]="!project.canContribute()"
[cdkDropListEnterPredicate]="canEnter"
(cdkDropListDropped)="drop($event)"
@if (laneMode() === 'none') {
<div class="ts-board" cdkDropListGroup>
@for (column of board.columns(); track column.status) {
<section
class="ts-column"
[class.is-over]="isOver(column)"
[attr.data-status]="column.status"
[style.--tone]="statusTone(column.status)"
>
@for (issue of column.issues; track issue.id) {
<ts-issue-card
cdkDrag
[cdkDragData]="issue"
[issue]="issue"
[workflow]="project.workflow()"
<ng-container
[ngTemplateOutlet]="columnHead"
[ngTemplateOutletContext]="{ column }"
/>
<ng-container
[ngTemplateOutlet]="columnList"
[ngTemplateOutletContext]="{ column, lane: '' }"
/>
</section>
}
</div>
} @else {
<!-- One scroll for heads and rows, so the columns stay aligned. -->
<div class="ts-lanes">
<div class="ts-board ts-board--heads">
@for (column of board.columns(); track column.status) {
<section
class="ts-column ts-column--head"
[class.is-over]="isOver(column)"
[style.--tone]="statusTone(column.status)"
>
<ng-container
[ngTemplateOutlet]="columnHead"
[ngTemplateOutletContext]="{ column }"
/>
} @empty {
<p class="ts-column__empty">Nada por aquí</p>
}
</div>
</section>
</section>
}
</div>
@for (lane of lanes(); track lane.key) {
<section class="ts-lane" [attr.aria-label]="laneLabel(lane.key)">
<h3 class="ts-lane__title">
{{ laneLabel(lane.key) }}
<span class="ts-mono ts-muted">{{ lane.count }}</span>
</h3>
<!-- Cards move between columns of their own row only. -->
<div class="ts-board" cdkDropListGroup>
@for (column of lane.columns; track column.status) {
<section
class="ts-column ts-column--lane"
[attr.data-status]="column.status"
[style.--tone]="statusTone(column.status)"
>
<ng-container
[ngTemplateOutlet]="columnList"
[ngTemplateOutletContext]="{ column, lane: lane.key }"
/>
</section>
}
</div>
</section>
}
</div>
}
}
<ng-template #columnHead let-column="column">
<header class="ts-column__header">
<span class="ts-column__name">{{ statusLabel(column.status) }}</span>
<span
class="ts-column__count"
[attr.title]="
wipOf(column) ? 'Límite WIP: ' + wipOf(column) + ' tickets' : null
"
>{{ column.total }}{{ wipOf(column) ? ' / ' + wipOf(column) : '' }}
@if (pointsOf(column); as points) {
<span class="ts-column__points" title="Puntos estimados"
>· {{ points }} pts</span
>
}
</span>
</header>
</ng-template>
<ng-template #columnList let-column="column" let-lane="lane">
<div
class="ts-column__list"
cdkDropList
[id]="lane + '|' + column.status"
[cdkDropListData]="column"
[cdkDropListDisabled]="!project.canContribute()"
[cdkDropListEnterPredicate]="canEnter"
(cdkDropListDropped)="drop($event)"
>
@for (issue of column.issues; track issue.id) {
<ts-issue-card
cdkDrag
[cdkDragData]="issue"
[issue]="issue"
[workflow]="project.workflow()"
/>
} @empty {
<p class="ts-column__empty">Nada por aquí</p>
}
</div>
}
</ng-template>
<ng-template #createForm>
<form
@@ -362,6 +460,7 @@ export class BoardPage {
protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly toast = inject(ToastService);
private readonly auth = inject(AuthFacade);
private readonly me = computed(() => this.auth.user()?.id ?? null);
private readonly createForm =
@@ -406,6 +505,59 @@ export class BoardPage {
),
);
/* Swimlanes and WIP */
protected readonly laneOptions: PiSelectFieldOption<string>[] = [
{ value: 'none', label: 'Sin agrupar' },
{ value: 'assignee', label: 'Agrupar por persona' },
{ value: 'epic', label: 'Agrupar por épica' },
{ value: 'priority', label: 'Agrupar por prioridad' },
{ value: 'type', label: 'Agrupar por tipo' },
];
protected readonly laneMode = signal<SwimlaneMode>(storedLaneMode());
protected readonly lanes = computed(() =>
swimlanes(this.board.columns(), this.laneMode(), (key) =>
this.laneMode() === 'priority'
? -PRIORITY_LEVEL[key as IssuePriority]
: 0,
),
);
protected laneLabel(key: string): string {
switch (this.laneMode()) {
case 'assignee':
return key
? (this.project.members().find((m) => m.user.id === key)?.user.name ??
'Antiguo miembro')
: 'Sin asignar';
case 'epic': {
if (!key) return 'Sin épica';
const card = this.board
.columns()
.flatMap((c) => c.issues)
.find((i) => i.parent?.id === key);
return card?.parent ? `${card.parent.key} · ${card.parent.title}` : key;
}
case 'priority':
return PRIORITY[key as IssuePriority]?.label ?? key;
case 'type':
return statusLookType(this.project.workflow(), key);
default:
return '';
}
}
protected setLaneMode(mode: string | null): void {
const next = (mode || 'none') as SwimlaneMode;
this.laneMode.set(next);
try {
localStorage.setItem(LANE_KEY, next);
} catch {
// Private mode: the choice lasts until the page closes.
}
}
protected wipOf = (column: BoardColumn) =>
this.project.workflow().wipLimit(column.status);
protected isOver = (column: BoardColumn) =>
overWip(column.total, this.wipOf(column));
/* Saved views */
protected readonly viewName = signal('');
protected readonly viewShared = signal(false);
@@ -582,6 +734,12 @@ export class BoardPage {
if (event.previousContainer === event.container) return;
const to = event.container.data.status;
const issueId = event.item.data.id;
const target = this.board.columns().find((c) => c.status === to);
const limit = this.project.workflow().wipLimit(to);
if (target && overWip(target.total + 1, limit))
this.toast.info(
`«${this.statusLabel(to)}» pasa de su límite WIP (${target.total + 1}/${limit}): mejor terminar algo antes de empezar más`,
);
if (this.project.workflow().requiresReason(to)) {
this.pendingMove = { issueId, to };
this.reasonModel.set({ text: '' });

View File

@@ -135,7 +135,8 @@ function idFrom(name: string): string {
</header>
<div class="ts-rows-head ts-status-grid" aria-hidden="true">
<span></span><span>Nombre</span><span>Categoría</span
><span>Color</span><span>Pide motivo</span><span></span>
><span>Color</span><span>Límite WIP</span><span>Pide motivo</span
><span></span>
</div>
<ol
class="ts-rows"
@@ -198,6 +199,21 @@ function idFrom(name: string): string {
</option>
}
</select>
<input
class="ts-input"
type="number"
min="1"
max="999"
placeholder="—"
[attr.aria-label]="'Límite WIP de ' + status.name"
title="Cuántos tickets debería tener la columna como mucho (vacío: sin límite)"
[value]="status.wipLimit ?? ''"
(change)="
edit('statuses', i, {
wipLimit: wipFrom($any($event.target).value),
})
"
/>
<label class="ts-check">
<input
type="checkbox"
@@ -693,7 +709,10 @@ function idFrom(name: string): string {
}
.ts-status-grid {
display: grid;
grid-template-columns: 1.25rem minmax(10rem, 1fr) 9rem 8rem 5.5rem 6.5rem;
grid-template-columns: 1.25rem minmax(
10rem,
1fr
) 9rem 8rem 5.5rem 5.5rem 6.5rem;
align-items: center;
gap: 0.5rem;
}
@@ -996,6 +1015,12 @@ export class SettingsPage {
?.to.includes(to);
}
/** '' (or anything below 1) clears the limit. */
protected wipFrom(value: string): number | null {
const limit = Math.round(Number(value));
return Number.isFinite(limit) && limit >= 1 ? Math.min(limit, 999) : null;
}
protected edit(section: Section, index: number, changes: object): void {
this.patch((d) => Object.assign(d[section][index], changes));
}