feat: pinned tickets, at hand on the home page and in search

Each person pins up to 20 tickets from the ticket's page («Fijar»). They
show as a strip under the projects on the home page and in the search
palette before typing. Private and without history, like the views of
«my work» (Mongo `pinned_issues`); tickets in the trash or out of reach
are left out, and when the list is full the unreadable ones are dropped
first.

API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL
myPins, pinIssue, unpinIssue and Issue.pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 10:15:28 +02:00
parent 423f82c1a3
commit dfa42a8a1a
32 changed files with 1612 additions and 46 deletions

View File

@@ -17,6 +17,7 @@ import {
BlockersService,
CommentsService,
IssuesService,
PinsService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
@@ -60,6 +61,7 @@ export class IssueResolver {
private readonly workflows: WorkflowsService,
private readonly blockers: BlockersService,
private readonly presence: PresenceService,
private readonly pins: PinsService,
) {}
@Mutation(() => [PresenceGql], {
@@ -245,6 +247,16 @@ export class IssueResolver {
return !!me && issue.voterIds.includes(me.id);
}
@ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)',
})
pinned(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.pins.isPinned(me.id, issue.id);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
linkedIssues(
@CurrentUser() me: User | undefined,
@@ -317,6 +329,35 @@ export class IssueResolver {
return this.issuesService.watch(me.id, issueId, true);
}
@Query(() => [IssueGql], {
description:
'My pinned tickets, most recently pinned first (trashed or out of reach left out)',
})
myPins(@CurrentUser() me: User) {
return this.pins.mine(me.id);
}
@Mutation(() => Boolean, {
description:
'Pin a ticket: keep it at hand on my home page and in search (private, at most 20)',
})
async pinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, { description: 'Unpin a ticket' })
async unpinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, false);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,

View File

@@ -32,6 +32,7 @@ import {
import {
CommentsService,
IssuesService,
PinsService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
@@ -94,6 +95,7 @@ export class IssuesController {
private readonly refs: Refs,
private readonly presence: PresenceService,
private readonly projects: ProjectsService,
private readonly pins: PinsService,
) {}
@Get('me/home')
@@ -163,6 +165,41 @@ export class IssuesController {
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
}
@Get('me/pins')
@ApiOperation({
summary: 'My pinned tickets, most recently pinned first',
description:
'Across my projects; tickets in the trash or out of my reach are left out.',
})
@ApiOkResponse({ type: [IssueDto] })
@ApiReadErrors()
async myPins(@CurrentUser() me: User) {
return this.refs.issueDtos(await this.pins.mine(me.id), viewerOf(me));
}
@Put('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Pin a ticket: keep it at hand on my home page and in search',
description: 'Private to me; at most 20. Pinning it again changes nothing.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async pin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), true);
}
@Delete('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Unpin a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unpin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({