feat: an ordered backlog

A Backlog tab lists the tickets still to start in the order the team will
take them; drag them, or use the arrows, to reorder. Moving a ticket only
rewrites that ticket's rank, read from the event store, and board columns
follow the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:01:44 +02:00
parent d4da33d4e8
commit 0606f53026
53 changed files with 1599 additions and 32 deletions

View File

@@ -18,6 +18,7 @@ import { map, Observable } from 'rxjs';
import {
AddChecklistItemDocument,
AssignIssueDocument,
BacklogDocument,
BoardDocument,
ChangeIssueStatusDocument,
CreateIssueDocument,
@@ -26,6 +27,7 @@ import {
IssueDocument,
LinkIssuesDocument,
MyIssuesDocument,
RankIssueDocument,
RemoveChecklistItemDocument,
SearchIssuesDocument,
SetIssueParentDocument,
@@ -166,6 +168,31 @@ export class GraphqlIssuesGateway extends IssuesGateway {
);
}
backlog(projectId: string): Observable<IssuePage> {
return this.gql
.query(BacklogDocument, {
filter: { projectId, categories: ['TODO'], sort: 'RANK' },
})
.pipe(
map((d) => ({
items: d.issues.items.map(toIssueSummary),
total: d.issues.total,
})),
);
}
rank(
issueId: string,
aboveIssueId: string | null,
belowIssueId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(RankIssueDocument, {
input: { issueId, aboveIssueId, belowIssueId },
})
.pipe(map((d) => toCommandResult(d.rankIssue)));
}
addChecklistItem(issueId: string, text: string): Observable<CommandResult> {
return this.gql
.mutate(AddChecklistItemDocument, { input: { issueId, text } })

View File

@@ -74,6 +74,7 @@ fragment IssueSummaryFields on Issue {
key
title
}
rank
}
fragment IssueFields on Issue {

View File

@@ -75,6 +75,7 @@ export type IssueFilterInput = {
releaseId?: string | null | undefined;
/** Matches key, title or description */
search?: string | null | undefined;
sort?: IssueSort | null | undefined;
statuses?: Array<string> | null | undefined;
types?: Array<string> | null | undefined;
unassigned?: boolean | null | undefined;
@@ -89,6 +90,9 @@ export type IssueLinkType =
export type IssuePriority = 'CRITICAL' | 'HIGH' | 'LOW' | 'LOWEST' | 'MEDIUM';
/** RECENT: last updated first. RANK: backlog order. */
export type IssueSort = 'RANK' | 'RECENT';
export type IssueTypeOptionInput = {
archived?: boolean;
color: string;
@@ -133,6 +137,14 @@ export type ProjectSettingsInput = {
transitions: Array<StatusTransitionsInput>;
};
export type RankIssueInput = {
/** The ticket that ends up right above (null: the top) */
aboveIssueId?: string | null | undefined;
/** The ticket that ends up right below (null: the bottom) */
belowIssueId?: string | null | undefined;
issueId: string;
};
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
/** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */
@@ -233,6 +245,7 @@ export type IssueSummaryFieldsFragment = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
@@ -258,6 +271,7 @@ export type IssueFieldsFragment = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -343,6 +357,7 @@ export type BoardQuery = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
@@ -376,6 +391,7 @@ export type IssueQuery = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -420,6 +436,7 @@ export type SearchIssuesQuery = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
@@ -570,6 +587,7 @@ export type MyIssuesQuery = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
@@ -597,6 +615,7 @@ export type IssueChildrenQuery = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
@@ -624,6 +643,7 @@ export type EpicsQuery = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
@@ -672,6 +692,43 @@ export type RemoveChecklistItemMutation = {
removeChecklistItem: { id: string; key: string | null; version: number };
};
export type BacklogQueryVariables = Exact<{
filter: IssueFilterInput;
}>;
export type BacklogQuery = {
issues: {
total: number;
items: Array<{
id: string;
projectId: string;
key: string;
type: string;
area: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
priority: IssuePriority;
labels: Array<string>;
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
};
};
export type RankIssueMutationVariables = Exact<{
input: RankIssueInput;
}>;
export type RankIssueMutation = {
rankIssue: { id: string; key: string | null; version: number };
};
export type NotificationFieldsFragment = {
id: string;
kind: NotificationKind;
@@ -1104,6 +1161,7 @@ export type IssueChangedSubscription = {
commentCount: number;
updatedAt: string;
version: number;
rank: string;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
@@ -1478,6 +1536,7 @@ export const IssueSummaryFieldsFragmentDoc = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -1680,6 +1739,7 @@ export const IssueFieldsFragmentDoc = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -2094,6 +2154,7 @@ export const BoardDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -2225,6 +2286,7 @@ export const IssueDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -2475,6 +2537,7 @@ export const SearchIssuesDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -3690,6 +3753,7 @@ export const MyIssuesDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -3835,6 +3899,7 @@ export const IssueChildrenDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -3998,6 +4063,7 @@ export const EpicsDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
@@ -4324,6 +4390,223 @@ export const RemoveChecklistItemDocument = {
RemoveChecklistItemMutation,
RemoveChecklistItemMutationVariables
>;
export const BacklogDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Backlog' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'filter' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'IssueFilterInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'issues' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'filter' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'filter' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'limit' },
value: { kind: 'IntValue', value: '200' },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'items' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'IssueSummaryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Issue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'release' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},
],
} as unknown as DocumentNode<BacklogQuery, BacklogQueryVariables>;
export const RankIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RankIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'RankIssueInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'rankIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<RankIssueMutation, RankIssueMutationVariables>;
export const MyNotificationsDocument = {
kind: 'Document',
definitions: [
@@ -7020,6 +7303,7 @@ export const IssueChangedDocument = {
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
],
},
},

View File

@@ -176,3 +176,18 @@ mutation RemoveChecklistItem($issueId: ID!, $itemId: ID!) {
...CommandResultFields
}
}
query Backlog($filter: IssueFilterInput!) {
issues(filter: $filter, limit: 200) {
items {
...IssueSummaryFields
}
total
}
}
mutation RankIssue($input: RankIssueInput!) {
rankIssue(input: $input) {
...CommandResultFields
}
}