feat: saved views of my work, across my projects

A person saves named views of «my work»: projects, assigned to me, that I
follow, status categories, priorities, bugs only, overdue and text. They
are private (Mongo my_views, no history), listed and deleted by their
owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views),
and run as the cross-project search, which gains project, priority,
bugsOnly and overdue filters (also in MCP my_issues). The home page shows
them as a bar above «my work», with a drawer to save a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:22:20 +02:00
parent e14f445e6b
commit 5b0396bc10
46 changed files with 2648 additions and 35 deletions

View File

@@ -10,6 +10,7 @@ export * from './lib/facades/explore.facade';
export * from './lib/facades/inbox.facade';
export * from './lib/facades/epics.facade';
export * from './lib/facades/insights.facade';
export * from './lib/facades/my-views.facade';
export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,32 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
IssuePage,
MyView,
MyViewCriteria,
} from '@task-stream/web/tracker/domain';
export const myViewsPageEvents = eventGroup({
source: 'My Work Views',
events: {
opened: type<void>(),
/** null: back to «Mis tickets». */
selected: type<string | null>(),
saveRequested: type<{ name: string; criteria: MyViewCriteria }>(),
deleteRequested: type<string>(),
},
});
export const myViewsApiEvents = eventGroup({
source: 'My Views API',
events: {
listed: type<MyView[]>(),
listFailed: type<string>(),
resultsLoaded: type<IssuePage>(),
resultsFailed: type<string>(),
saved: type<string>(),
saveFailed: type<string>(),
deleted: type<string>(),
deleteFailed: type<string>(),
},
});

View File

@@ -0,0 +1,41 @@
import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { MyViewCriteria } from '@task-stream/web/tracker/domain';
import { myViewsPageEvents } from '../events/my-views.events';
import { MyViewsStore, VIEW_LIMIT } from '../stores/my-views.store';
/** My saved views of «my work», across my projects. */
@Injectable({ providedIn: 'root' })
export class MyViewsFacade {
private readonly store = inject(MyViewsStore);
private readonly dispatch = injectDispatch(myViewsPageEvents);
/** By name. */
readonly views = this.store.views;
/** The one picked (null: «Mis tickets»). */
readonly selected = this.store.selected;
/** Its tickets, most recently updated first (up to `limit`). */
readonly results = this.store.results;
readonly total = this.store.total;
readonly limit = VIEW_LIMIT;
readonly loading = this.store.loading;
readonly saving = this.store.saving;
readonly error = this.store.error;
/** Loads my views (and refreshes the one picked). */
open(): void {
this.dispatch.opened();
}
select(viewId: string | null): void {
this.dispatch.selected(viewId);
}
save(name: string, criteria: MyViewCriteria): void {
this.dispatch.saveRequested({ name, criteria });
}
remove(viewId: string): void {
this.dispatch.deleteRequested(viewId);
}
}

View File

@@ -0,0 +1,180 @@
import { computed, inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
IssuesGateway,
IssueSummary,
MyIssuesFilter,
MyView,
MyViewCriteria,
OPEN_CATEGORIES,
SavedFiltersGateway,
} from '@task-stream/web/tracker/domain';
import { filter, map, merge, mergeMap, switchMap, take, tap } from 'rxjs';
import { myViewsApiEvents, myViewsPageEvents } from '../events/my-views.events';
import { describeError, Notifications } from '../notifications';
/** Tickets a view lists at most. */
export const VIEW_LIMIT = 100;
/** A view's criteria as the «my work» search; no statuses: the open ones. */
export function viewFilter(criteria: MyViewCriteria): MyIssuesFilter {
return {
assignedToMe: !!criteria.assignedToMe,
watching: !!criteria.watching,
categories: criteria.categories?.length
? criteria.categories
: [...OPEN_CATEGORIES],
priorities: criteria.priorities ?? undefined,
projectIds: criteria.projectIds ?? undefined,
bugsOnly: !!criteria.bugsOnly,
overdue: !!criteria.overdue,
search: criteria.search ?? undefined,
limit: VIEW_LIMIT,
};
}
interface MyViewsState {
views: MyView[];
selectedId: string | null;
results: IssueSummary[];
total: number;
loading: boolean;
saving: boolean;
error: string | null;
}
/** My saved views of «my work» and the tickets of the one picked. Root-scoped. */
export const MyViewsStore = signalStore(
{ providedIn: 'root' },
withState<MyViewsState>({
views: [],
selectedId: null,
results: [],
total: 0,
loading: false,
saving: false,
error: null,
}),
withComputed((store) => ({
selected: computed(
() => store.views().find((v) => v.id === store.selectedId()) ?? null,
),
})),
withReducer(
on(myViewsApiEvents.listed, ({ payload }, state) => ({
views: payload,
// A view deleted elsewhere is no longer picked.
selectedId: payload.some((v) => v.id === state.selectedId)
? state.selectedId
: null,
})),
on(myViewsPageEvents.selected, ({ payload }) => ({
selectedId: payload,
results: [],
total: 0,
loading: payload !== null,
error: null,
})),
on(myViewsApiEvents.resultsLoaded, ({ payload }) => ({
results: payload.items,
total: payload.total,
loading: false,
})),
on(myViewsApiEvents.resultsFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
on(myViewsPageEvents.saveRequested, () => ({ saving: true })),
on(myViewsApiEvents.saved, () => ({ saving: false })),
on(myViewsApiEvents.saveFailed, () => ({ saving: false })),
),
withEventHandlers(
(
store,
events = inject(Events),
filters = inject(SavedFiltersGateway),
issues = inject(IssuesGateway),
notifications = inject(Notifications),
) => ({
list$: merge(
events.on(myViewsPageEvents.opened),
events.on(myViewsApiEvents.saved, myViewsApiEvents.deleted),
).pipe(
switchMap(() =>
filters.myViews().pipe(
mapResponse({
next: (views) => myViewsApiEvents.listed(views),
error: (error) =>
myViewsApiEvents.listFailed(describeError(error)),
}),
),
),
),
// The picked view's tickets: on picking it, and again on opening.
results$: merge(
events
.on(myViewsPageEvents.selected)
.pipe(map(({ payload }) => payload)),
events.on(myViewsPageEvents.opened).pipe(map(() => store.selectedId())),
).pipe(
filter((id): id is string => !!id),
switchMap((id) => {
const view = store.views().find((v) => v.id === id);
if (!view) return [];
return issues.mine(viewFilter(view.criteria)).pipe(
mapResponse({
next: (page) => myViewsApiEvents.resultsLoaded(page),
error: (error) =>
myViewsApiEvents.resultsFailed(describeError(error)),
}),
);
}),
),
save$: events.on(myViewsPageEvents.saveRequested).pipe(
mergeMap(({ payload }) =>
filters.saveMyView(payload).pipe(
mapResponse({
next: (id) => myViewsApiEvents.saved(id),
error: (error) =>
myViewsApiEvents.saveFailed(describeError(error)),
}),
),
),
),
// Once listed again, the new view is the one shown.
selectSaved$: events.on(myViewsApiEvents.saved).pipe(
switchMap(({ payload }) =>
events.on(myViewsApiEvents.listed).pipe(
take(1),
map(() => myViewsPageEvents.selected(payload)),
),
),
),
delete$: events.on(myViewsPageEvents.deleteRequested).pipe(
mergeMap(({ payload }) =>
filters.removeMyView(payload).pipe(
mapResponse({
next: () => myViewsApiEvents.deleted(payload),
error: (error) =>
myViewsApiEvents.deleteFailed(describeError(error)),
}),
),
),
),
feedback$: events
.on(
myViewsApiEvents.saveFailed,
myViewsApiEvents.deleteFailed,
myViewsApiEvents.listFailed,
)
.pipe(tap(({ payload }) => notifications.error(payload))),
}),
),
);

View File

@@ -122,6 +122,12 @@ export interface MyIssuesFilter {
categories?: StatusCategory[];
/** Matches key, title or description. */
search?: string;
projectIds?: string[];
priorities?: IssuePriority[];
/** Only types drawn with the bug icon. */
bugsOnly?: boolean;
/** Only open tickets past their due date. */
overdue?: boolean;
limit: number;
}

View File

@@ -87,6 +87,29 @@ export interface SavedFilterCriteria {
search?: string | null;
}
/** What a view of «my work» narrows to, across my projects. */
export interface MyViewCriteria {
/** Only these projects (empty: all of mine). */
projectIds?: string[] | null;
assignedToMe?: boolean | null;
watching?: boolean | null;
categories?: StatusCategory[] | null;
priorities?: IssuePriority[] | null;
/** Only types drawn with the bug icon. */
bugsOnly?: boolean | null;
/** Only open tickets past their due date. */
overdue?: boolean | null;
search?: string | null;
}
/** A named view of «my work», mine only. */
export interface MyView {
id: string;
name: string;
criteria: MyViewCriteria;
createdAt: string;
}
/** A named view of the board: private, or shared with the project. */
export interface SavedFilter {
id: string;

View File

@@ -25,6 +25,8 @@ import {
Release,
ReleaseBurnup,
SavedFilter,
MyView,
MyViewCriteria,
SavedFilterCriteria,
Sprint,
SprintBurndown,
@@ -243,6 +245,13 @@ export abstract class SavedFiltersGateway {
criteria: SavedFilterCriteria;
}): Observable<string>;
abstract remove(id: string): Observable<void>;
/** My views of «my work», by name. */
abstract myViews(): Observable<MyView[]>;
abstract saveMyView(input: {
name: string;
criteria: MyViewCriteria;
}): Observable<string>;
abstract removeMyView(id: string): Observable<void>;
}
export abstract class CommentsGateway {

View File

@@ -102,6 +102,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
categories: filter.categories as never,
limit: filter.limit,
search: filter.search?.trim() || null,
projectIds: filter.projectIds?.length ? filter.projectIds : null,
priorities: filter.priorities?.length
? (filter.priorities as never)
: null,
bugsOnly: filter.bugsOnly ?? null,
overdue: filter.overdue ?? null,
})
.pipe(
map((d) => ({

View File

@@ -1,11 +1,16 @@
import { inject, Injectable } from '@angular/core';
import {
MyView,
MyViewCriteria,
SavedFilter,
SavedFilterCriteria,
SavedFiltersGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
DeleteMyViewDocument,
MyViewsDocument,
SaveMyViewDocument,
DeleteSavedFilterDocument,
SavedFiltersDocument,
SaveFilterDocument,
@@ -38,4 +43,25 @@ export class GraphqlSavedFiltersGateway extends SavedFiltersGateway {
.mutate(DeleteSavedFilterDocument, { id })
.pipe(map(() => undefined));
}
myViews(): Observable<MyView[]> {
return this.gql
.query(MyViewsDocument, {})
.pipe(map((d) => d.myViews as MyView[]));
}
saveMyView(input: {
name: string;
criteria: MyViewCriteria;
}): Observable<string> {
return this.gql
.mutate(SaveMyViewDocument, { input: input as never })
.pipe(map((d) => d.saveMyView));
}
removeMyView(id: string): Observable<void> {
return this.gql
.mutate(DeleteMyViewDocument, { id })
.pipe(map(() => undefined));
}
}

View File

@@ -236,6 +236,18 @@ export type LinkIssuesInput = {
type: IssueLinkType;
};
export type MyViewCriteriaInput = {
assignedToMe?: boolean | null | undefined;
bugsOnly?: boolean | null | undefined;
categories?: Array<StatusCategory> | null | undefined;
overdue?: boolean | null | undefined;
priorities?: Array<IssuePriority> | null | undefined;
/** Empty: all my projects */
projectIds?: Array<string> | null | undefined;
search?: string | null | undefined;
watching?: boolean | null | undefined;
};
/** Why someone is told */
export type NotificationKind =
| 'ADDED_TO_PROJECT'
@@ -293,6 +305,11 @@ export type SaveFilterInput = {
shared?: boolean;
};
export type SaveMyViewInput = {
criteria: MyViewCriteriaInput;
name: string;
};
export type SavedFilterCriteriaInput = {
areas?: Array<string> | null | undefined;
assigneeId?: string | null | undefined;
@@ -1023,6 +1040,10 @@ export type MyIssuesQueryVariables = Exact<{
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
limit: number;
search?: string | null | undefined;
projectIds?: Array<string> | string | null | undefined;
priorities?: Array<IssuePriority> | IssuePriority | null | undefined;
bugsOnly?: boolean | null | undefined;
overdue?: boolean | null | undefined;
}>;
export type MyIssuesQuery = {
@@ -2117,6 +2138,38 @@ export type DeleteSavedFilterMutationVariables = Exact<{
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
export type MyViewsQueryVariables = Exact<{ [key: string]: never }>;
export type MyViewsQuery = {
myViews: Array<{
id: string;
name: string;
createdAt: string;
criteria: {
projectIds: Array<string> | null;
assignedToMe: boolean | null;
watching: boolean | null;
categories: Array<StatusCategory> | null;
priorities: Array<IssuePriority> | null;
bugsOnly: boolean | null;
overdue: boolean | null;
search: string | null;
};
}>;
};
export type SaveMyViewMutationVariables = Exact<{
input: SaveMyViewInput;
}>;
export type SaveMyViewMutation = { saveMyView: string };
export type DeleteMyViewMutationVariables = Exact<{
id: string;
}>;
export type DeleteMyViewMutation = { deleteMyView: boolean };
export type SprintFieldsFragment = {
id: string;
projectId: string;
@@ -6604,6 +6657,53 @@ export const MyIssuesDocument = {
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectIds' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'IssuePriority' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'bugsOnly' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'overdue' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
],
selectionSet: {
kind: 'SelectionSet',
@@ -6652,6 +6752,38 @@ export const MyIssuesDocument = {
name: { kind: 'Name', value: 'search' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectIds' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectIds' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'priorities' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'bugsOnly' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'bugsOnly' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'overdue' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'overdue' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
@@ -12637,6 +12769,161 @@ export const DeleteSavedFilterDocument = {
DeleteSavedFilterMutation,
DeleteSavedFilterMutationVariables
>;
export const MyViewsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'MyViews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'myViews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'criteria' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'projectIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'assignedToMe' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'watching' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'categories' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'priorities' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugsOnly' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overdue' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'search' },
},
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<MyViewsQuery, MyViewsQueryVariables>;
export const SaveMyViewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'SaveMyView' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SaveMyViewInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'saveMyView' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<SaveMyViewMutation, SaveMyViewMutationVariables>;
export const DeleteMyViewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'DeleteMyView' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'deleteMyView' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'id' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'id' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
DeleteMyViewMutation,
DeleteMyViewMutationVariables
>;
export const SprintsDocument = {
kind: 'Document',
definitions: [

View File

@@ -136,6 +136,10 @@ query MyIssues(
$categories: [StatusCategory!]
$limit: Int!
$search: String
$projectIds: [ID!]
$priorities: [IssuePriority!]
$bugsOnly: Boolean
$overdue: Boolean
) {
myIssues(
assignedToMe: $assignedToMe
@@ -143,6 +147,10 @@ query MyIssues(
categories: $categories
limit: $limit
search: $search
projectIds: $projectIds
priorities: $priorities
bugsOnly: $bugsOnly
overdue: $overdue
) {
total
items {

View File

@@ -32,3 +32,29 @@ mutation SaveFilter($input: SaveFilterInput!) {
mutation DeleteSavedFilter($id: ID!) {
deleteSavedFilter(id: $id)
}
query MyViews {
myViews {
id
name
createdAt
criteria {
projectIds
assignedToMe
watching
categories
priorities
bugsOnly
overdue
search
}
}
}
mutation SaveMyView($input: SaveMyViewInput!) {
saveMyView(input: $input)
}
mutation DeleteMyView($id: ID!) {
deleteMyView(id: $id)
}

View File

@@ -32,6 +32,7 @@ import {
typeLook,
} from '../ui/copy';
import { CalendarFeedComponent } from '../ui/calendar-feed';
import { MyViewsComponent } from '../ui/my-views';
const VISIBLE = 12;
@@ -49,6 +50,7 @@ const VISIBLE = 12;
PiIconComponent,
PiSkeletonComponent,
CalendarFeedComponent,
MyViewsComponent,
],
template: `
@if (!home.loaded()) {
@@ -60,6 +62,7 @@ const VISIBLE = 12;
</div>
</div>
} @else {
<ts-my-views />
<div class="ts-work">
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
<header class="ts-work__head">

View File

@@ -0,0 +1,481 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiCheckboxComponent,
PiLabelDirective,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
MyViewsFacade,
ProjectsFacade,
} from '@task-stream/web/tracker/application';
import {
IssuePriority,
IssueSummary,
MyViewCriteria,
PRIORITY_ORDER,
StatusCategory,
Workflow,
} from '@task-stream/web/tracker/domain';
import {
CATEGORY,
PRIORITY,
projectKeyOf,
projectTone,
statusLook,
typeLook,
} from './copy';
const CATEGORIES = Object.values(StatusCategory);
/** Toggles a value in a list kept as a signal. */
function toggle<T>(
list: ReturnType<typeof signal<T[]>>,
value: T,
on: boolean,
) {
list.update((values) =>
on ? [...new Set([...values, value])] : values.filter((v) => v !== value),
);
}
/**
* My saved views of «my work», across my projects: a bar to pick one, its
* tickets, and the drawer to save a new one.
*/
@Component({
selector: 'ts-my-views',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
PiActionButtonComponent,
PiCheckboxComponent,
PiLabelDirective,
PiTextFieldComponent,
],
template: `
<nav class="ts-views" aria-label="Mis vistas">
<span class="ts-views__label">Vistas</span>
@for (view of views.views(); track view.id) {
<button
type="button"
class="ts-views__chip"
[class.is-on]="views.selected()?.id === view.id"
[attr.aria-pressed]="views.selected()?.id === view.id"
(click)="
views.select(views.selected()?.id === view.id ? null : view.id)
"
>
{{ view.name }}
</button>
}
<button type="button" class="ts-views__new" (click)="openForm()">
+ Nueva vista
</button>
</nav>
@if (views.selected(); as view) {
<section class="ts-panel ts-views__panel" aria-labelledby="view-title">
<header class="ts-views__head">
<h2 id="view-title">{{ view.name }}</h2>
<span class="ts-muted">{{ summary() }}</span>
<span class="ts-muted ts-mono">{{ views.total() }}</span>
<span class="ts-spacer"></span>
<button
type="button"
class="ts-plain-link"
(click)="views.remove(view.id)"
>
Borrar vista
</button>
<button
type="button"
class="ts-plain-link"
(click)="views.select(null)"
>
Cerrar
</button>
</header>
@if (views.loading()) {
<p class="ts-muted">Cargando…</p>
} @else if (views.error(); as error) {
<p class="ts-muted">{{ error }}</p>
} @else {
<ul class="ts-views__list">
@for (issue of views.results(); track issue.id) {
<li>
<a
class="ts-views__row"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="typeTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-views__title"
><span
class="ts-views__status"
[style.--tone]="statusTone(issue)"
>{{ statusLabel(issue) }}</span
>
{{ issue.title }}</span
>
<span class="ts-views__who">{{
issue.assignee?.name ?? 'Sin asignar'
}}</span>
<span
class="ts-views__project"
[style.--tone]="projectToneOf(issue.key)"
>{{ keyOf(issue.key) }}</span
>
</a>
</li>
} @empty {
<li class="ts-muted">Ningún ticket encaja ahora mismo.</li>
}
</ul>
@if (views.total() > views.results().length) {
<p class="ts-muted ts-views__more">
Se muestran {{ views.results().length }} de {{ views.total() }}.
</p>
}
}
</section>
}
<ng-template #form>
<form
class="ts-form ts-views__form"
novalidate
(submit)="$event.preventDefault(); save()"
>
<pi-text-field [(value)]="name" placeholder="Bugs críticos">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<fieldset>
<legend>Proyectos <span>(ninguno: todos los míos)</span></legend>
@for (project of projects.sorted(); track project.id) {
<pi-checkbox
[label]="project.key + ' · ' + project.name"
[checked]="projectIds().includes(project.id)"
(checkedChange)="pick(projectIds, project.id, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Quién</legend>
<pi-checkbox
label="Asignados a mí"
[checked]="assignedToMe()"
(checkedChange)="assignedToMe.set($event)"
/>
<pi-checkbox
label="Que sigo"
[checked]="watching()"
(checkedChange)="watching.set($event)"
/>
</fieldset>
<fieldset>
<legend>Estado <span>(ninguno: los abiertos)</span></legend>
@for (category of categories; track category) {
<pi-checkbox
[label]="categoryLabel(category)"
[checked]="categoriesPicked().includes(category)"
(checkedChange)="pick(categoriesPicked, category, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Prioridad <span>(ninguna: todas)</span></legend>
@for (priority of priorities; track priority) {
<pi-checkbox
[label]="priorityLabel(priority)"
[checked]="prioritiesPicked().includes(priority)"
(checkedChange)="pick(prioritiesPicked, priority, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Además</legend>
<pi-checkbox
label="Solo bugs"
[checked]="bugsOnly()"
(checkedChange)="bugsOnly.set($event)"
/>
<pi-checkbox
label="Solo vencidos"
[checked]="overdue()"
(checkedChange)="overdue.set($event)"
/>
</fieldset>
<pi-text-field [(value)]="search" placeholder="pago, Safari…">
<pi-label>Texto (clave, título o descripción)</pi-label>
</pi-text-field>
<pi-action-button
size="w100"
color="primary"
[text]="views.saving() ? 'Guardando…' : 'Guardar vista'"
[disabled]="!name().trim() || views.saving()"
(execute)="save()"
/>
</form>
</ng-template>
`,
styles: `
:host {
display: grid;
gap: 1rem;
margin-bottom: 1.25rem;
}
.ts-views {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.ts-views__label {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-views__chip,
.ts-views__new {
padding: 0.3125rem 0.75rem;
border: 1px solid var(--ts-line);
border-radius: 999px;
background: var(--ts-surface);
color: var(--ts-ink);
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
border-color: var(--ts-brand);
}
}
.ts-views__chip.is-on {
border-color: var(--ts-brand);
background: color-mix(in srgb, var(--ts-brand) 14%, var(--ts-surface));
font-weight: 600;
}
.ts-views__new {
border-style: dashed;
color: var(--ts-brand);
}
.ts-views__panel {
padding: 1.25rem 1.5rem;
}
.ts-views__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
h2 {
margin: 0;
font-size: 1.125rem;
}
.ts-muted {
font-size: 0.8125rem;
}
}
.ts-spacer {
flex: 1;
}
.ts-views__list {
display: grid;
gap: 0.125rem;
margin: 0;
padding: 0;
list-style: none;
}
.ts-views__row {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr) minmax(0, 9rem) 3.5rem;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-left: 3px solid var(--type, var(--ts-line));
border-radius: 0.375rem;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
&:hover {
background: var(--pi-color-primary-25);
}
}
.ts-views__title,
.ts-views__who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-views__who {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-views__status {
margin-right: 0.375rem;
color: var(--tone);
font-size: 0.75rem;
font-weight: 600;
}
.ts-views__project {
justify-self: end;
color: var(--tone);
font-family: var(--ts-font-mono);
font-size: 0.75rem;
}
.ts-views__more {
margin: 0.75rem 0 0;
font-size: 0.75rem;
}
.ts-views__form fieldset {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
}
.ts-views__form fieldset + pi-text-field {
margin-top: 0.75rem;
}
.ts-views__form legend {
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
span {
color: var(--ts-muted);
font-weight: 400;
}
}
@media (max-width: 640px) {
.ts-views__row {
grid-template-columns: 5rem minmax(0, 1fr) 3rem;
}
.ts-views__who {
display: none;
}
}
`,
})
export class MyViewsComponent implements OnInit {
protected readonly views = inject(MyViewsFacade);
protected readonly projects = inject(ProjectsFacade);
private readonly drawer = inject(DrawerService);
private readonly form = viewChild.required<TemplateRef<unknown>>('form');
protected readonly categories = CATEGORIES;
protected readonly priorities = PRIORITY_ORDER;
/* The form */
protected readonly name = signal('');
protected readonly projectIds = signal<string[]>([]);
protected readonly assignedToMe = signal(false);
protected readonly watching = signal(false);
protected readonly categoriesPicked = signal<StatusCategory[]>([]);
protected readonly prioritiesPicked = signal<IssuePriority[]>([]);
protected readonly bugsOnly = signal(false);
protected readonly overdue = signal(false);
protected readonly search = signal('');
ngOnInit(): void {
this.views.open();
}
protected readonly pick = toggle;
protected openForm(): void {
this.name.set('');
this.projectIds.set([]);
this.assignedToMe.set(false);
this.watching.set(false);
this.categoriesPicked.set([]);
this.prioritiesPicked.set([]);
this.bugsOnly.set(false);
this.overdue.set(false);
this.search.set('');
this.drawer.open('Nueva vista de mi trabajo', this.form());
}
protected save(): void {
const name = this.name().trim();
if (!name) return;
const criteria: MyViewCriteria = {
projectIds: this.projectIds(),
assignedToMe: this.assignedToMe(),
watching: this.watching(),
categories: this.categoriesPicked(),
priorities: this.prioritiesPicked(),
bugsOnly: this.bugsOnly(),
overdue: this.overdue(),
search: this.search().trim() || null,
};
this.views.save(name, criteria);
this.drawer.closeAll();
}
/** What the picked view narrows to, in words. */
protected readonly summary = computed(() => {
const c = this.views.selected()?.criteria;
if (!c) return '';
const keys = (c.projectIds ?? [])
.map((id) => this.projects.projects().find((p) => p.id === id)?.key)
.filter(Boolean);
return [
keys.length ? keys.join(', ') : 'todos mis proyectos',
c.assignedToMe ? 'asignados a mí' : null,
c.watching ? 'que sigo' : null,
c.categories?.length
? c.categories.map((x) => CATEGORY[x].label.toLowerCase()).join('/')
: 'abiertos',
c.priorities?.length
? c.priorities.map((p) => PRIORITY[p].label.toLowerCase()).join('/')
: null,
c.bugsOnly ? 'solo bugs' : null,
c.overdue ? 'vencidos' : null,
c.search ? `«${c.search}»` : null,
]
.filter(Boolean)
.join(' · ');
});
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
protected readonly priorityLabel = (p: IssuePriority) => PRIORITY[p].label;
protected readonly keyOf = projectKeyOf;
protected readonly projectToneOf = (issueKey: string) =>
projectTone(projectKeyOf(issueKey));
/** Each ticket with its own project's names and colours. */
private readonly workflows = computed(
() =>
new Map(
this.projects
.projects()
.map((p) => [p.id, new Workflow(p.settings)] as const),
),
);
private workflowOf(issue: IssueSummary): Workflow {
return this.workflows().get(issue.projectId) ?? new Workflow();
}
protected readonly statusLabel = (issue: IssueSummary) =>
statusLook(this.workflowOf(issue), issue.status).label;
protected readonly statusTone = (issue: IssueSummary) =>
statusLook(this.workflowOf(issue), issue.status).tone;
protected readonly typeTone = (issue: IssueSummary) =>
typeLook(this.workflowOf(issue), issue.type).tone;
}