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))),
}),
),
);