first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import {
BoardColumn,
IssueStatus,
IssueSummary,
} from '@task-stream/web/tracker/domain';
import { applyIssueChange, moveIssue } from './board.reducers';
const issue = (
id: string,
status: IssueStatus,
version = 1,
assignee: string | null = null,
): IssueSummary =>
({
id,
status,
version,
key: id,
projectId: 'p',
title: id,
assignee: assignee ? { id: assignee, name: assignee } : null,
release: null,
area: null,
labels: [],
commentCount: 0,
}) as unknown as IssueSummary;
const board = (): BoardColumn[] => [
{
status: IssueStatus.TODO,
total: 1,
issues: [issue('a', IssueStatus.TODO)],
},
{ status: IssueStatus.IN_PROGRESS, total: 0, issues: [] },
];
describe('board reducers', () => {
it('moves a card optimistically', () => {
const next = moveIssue(board(), 'a', IssueStatus.IN_PROGRESS);
expect(next[0]).toMatchObject({ total: 0, issues: [] });
expect(next[1].issues[0]).toMatchObject({
id: 'a',
status: IssueStatus.IN_PROGRESS,
});
expect(next[1].total).toBe(1);
});
it('applies newer realtime versions and ignores stale ones', () => {
const moved = applyIssueChange(
board(),
issue('a', IssueStatus.IN_PROGRESS, 2),
null,
);
expect(moved[1].issues.map((i) => i.id)).toEqual(['a']);
const stale = applyIssueChange(
moved,
issue('a', IssueStatus.TODO, 1),
null,
);
expect(stale).toBe(moved);
});
it('drops issues that no longer match the filter', () => {
const next = applyIssueChange(
board(),
issue('a', IssueStatus.TODO, 2, 'bob'),
{ projectId: 'p', assigneeId: 'ana' },
);
expect(next[0].issues).toEqual([]);
});
it('adds new issues from other users', () => {
const next = applyIssueChange(
board(),
issue('b', IssueStatus.TODO, 1),
null,
);
expect(next[0].issues.map((i) => i.id)).toEqual(['b', 'a']);
});
});

View File

@@ -0,0 +1,80 @@
import {
BoardColumn,
BoardFilter,
IssueStatus,
IssueSummary,
} from '@task-stream/web/tracker/domain';
/** Pure board transformations (unit tested). */
export function findIssue(
columns: BoardColumn[],
issueId: string,
): IssueSummary | undefined {
for (const column of columns) {
const issue = column.issues.find((i) => i.id === issueId);
if (issue) return issue;
}
return undefined;
}
export function matchesFilter(
issue: IssueSummary,
filter: BoardFilter | null,
): boolean {
if (!filter) return true;
if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
return false;
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
if (filter.area && issue.area !== filter.area) return false;
return true;
}
/** Removes the issue wherever it is and puts `next` (if any) on top of its status column. */
export function placeIssue(
columns: BoardColumn[],
issueId: string,
next: IssueSummary | null,
): BoardColumn[] {
return columns.map((column) => {
const had = column.issues.some((i) => i.id === issueId);
let issues = had
? column.issues.filter((i) => i.id !== issueId)
: column.issues;
let total = column.total - (had ? 1 : 0);
if (next && next.status === column.status) {
issues = [next, ...issues];
total += 1;
}
return had || issues !== column.issues
? { ...column, issues, total }
: column;
});
}
/** Optimistic move. */
export function moveIssue(
columns: BoardColumn[],
issueId: string,
to: IssueStatus,
): BoardColumn[] {
const issue = findIssue(columns, issueId);
return issue
? placeIssue(columns, issueId, { ...issue, status: to })
: columns;
}
/** Merges a realtime change, ignoring stale versions and issues outside the filter. */
export function applyIssueChange(
columns: BoardColumn[],
issue: IssueSummary,
filter: BoardFilter | null,
): BoardColumn[] {
const current = findIssue(columns, issue.id);
if (current && current.version > issue.version) return columns;
return placeIssue(
columns,
issue.id,
matchesFilter(issue, filter) ? issue : null,
);
}

View File

@@ -0,0 +1,155 @@
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 {
BoardColumn,
BoardFilter,
IssuesGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
distinctUntilChanged,
exhaustMap,
map,
merge,
mergeMap,
switchMap,
tap,
} from 'rxjs';
import {
boardApiEvents,
boardPageEvents,
boardRealtimeEvents,
} from '../events/board.events';
import { describeError, Notifications } from '../notifications';
import { applyIssueChange, moveIssue } from './board.reducers';
interface BoardState {
filter: BoardFilter | null;
columns: BoardColumn[];
loading: boolean;
creating: boolean;
error: string | null;
}
/** Kanban board of a project, live. Scoped to the board route. */
export const BoardStore = signalStore(
withState<BoardState>({
filter: null,
columns: [],
loading: false,
creating: false,
error: null,
}),
withComputed(({ columns }) => ({
total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)),
})),
withReducer(
on(
boardPageEvents.opened,
boardPageEvents.filterChanged,
({ payload }) => ({ filter: payload, loading: true, error: null }),
),
on(boardApiEvents.loaded, ({ payload }) => ({
columns: payload,
loading: false,
})),
on(boardApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
on(boardPageEvents.issueMoved, ({ payload }, state) => ({
columns: moveIssue(state.columns, payload.issueId, payload.to),
})),
on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({
columns: applyIssueChange(state.columns, payload.issue, state.filter),
})),
on(boardPageEvents.issueCreateRequested, () => ({ creating: true })),
on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({
creating: false,
})),
),
withEventHandlers(
(
store,
events = inject(Events),
issues = inject(IssuesGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
) => {
const load = (filter: BoardFilter) =>
issues.board(filter).pipe(
mapResponse({
next: (columns) => boardApiEvents.loaded(columns),
error: (error) => boardApiEvents.loadFailed(describeError(error)),
}),
);
return {
load$: events
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
.pipe(switchMap(({ payload }) => load(payload))),
live$: events.on(boardPageEvents.opened).pipe(
map(({ payload }) => payload.projectId),
distinctUntilChanged(),
switchMap((projectId) => realtime.issueChanges(projectId)),
map((change) => boardRealtimeEvents.issueChanged(change)),
),
move$: events.on(boardPageEvents.issueMoved).pipe(
mergeMap(({ payload }) =>
issues
.changeStatus({
issueId: payload.issueId,
status: payload.to,
reason: payload.reason,
})
.pipe(
mapResponse({
next: () => null,
error: (error) =>
boardApiEvents.issueMoveFailed(describeError(error)),
}),
),
),
),
/** The API refused the move: tell the user and put the card back. */
rollback$: events.on(boardApiEvents.issueMoveFailed).pipe(
tap(({ payload }) => notifications.error(payload)),
switchMap(() => {
const filter = store.filter();
return filter ? load(filter) : [];
}),
),
create$: events.on(boardPageEvents.issueCreateRequested).pipe(
exhaustMap(({ payload }) =>
issues.create(payload).pipe(
mapResponse({
next: (result) => boardApiEvents.issueCreated(result),
error: (error) =>
boardApiEvents.issueCreateFailed(describeError(error)),
}),
),
),
),
feedback$: merge(
events
.on(boardApiEvents.issueCreated)
.pipe(
tap(({ payload }) =>
notifications.success(`${payload.key} creado`),
),
),
events
.on(boardApiEvents.issueCreateFailed, boardApiEvents.loadFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
),
};
},
),
);

View File

@@ -0,0 +1,21 @@
import { mapResponse } from '@ngrx/operators';
import { EventInstance } from '@ngrx/signals/events';
import { Observable } from 'rxjs';
import { describeError } from '../notifications';
type Creator = (message: string) => EventInstance<string, string>;
/** Runs a command and turns its outcome into a succeeded/failed event. */
export function runCommand<T>(
command$: Observable<T>,
successMessage: string,
succeeded: Creator,
failed: Creator,
) {
return command$.pipe(
mapResponse({
next: () => succeeded(successMessage),
error: (error) => failed(describeError(error)),
}),
);
}

View File

@@ -0,0 +1,313 @@
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 { AuthFacade } from '@task-stream/web/identity/application';
import {
Activity,
ActivityGateway,
Comment,
CommentsGateway,
Issue,
IssuesGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
debounceTime,
filter,
map,
merge,
mergeMap,
retry,
switchMap,
tap,
throwError,
timer,
} from 'rxjs';
import {
issueApiEvents,
issuePageEvents,
issueRealtimeEvents,
} from '../events/issue.events';
import { describeError, isNotFound, Notifications } from '../notifications';
import { runCommand } from './command';
interface IssueState {
issue: Issue | null;
comments: Comment[];
history: Activity[];
loading: boolean;
error: string | null;
}
/** One ticket: details, comments and history, live. Scoped to the issue view. */
export const IssueStore = signalStore(
withState<IssueState>({
issue: null,
comments: [],
history: [],
loading: false,
error: null,
}),
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
me: computed(() => auth.user()?.id ?? null),
outwardLinks: computed(
() => issue()?.links.filter((l) => l.direction === 'OUTWARD') ?? [],
),
inwardLinks: computed(
() => issue()?.links.filter((l) => l.direction === 'INWARD') ?? [],
),
})),
withReducer(
on(issuePageEvents.opened, () => ({
loading: true,
error: null,
issue: null,
comments: [],
history: [],
})),
on(issueApiEvents.loaded, ({ payload }) => ({
issue: payload,
loading: false,
})),
on(issueApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
on(issueApiEvents.commentsLoaded, ({ payload }) => ({ comments: payload })),
on(issueApiEvents.historyLoaded, ({ payload }) => ({ history: payload })),
on(issueRealtimeEvents.commentChanged, ({ payload }, state) => {
const others = state.comments.filter((c) => c.id !== payload.commentId);
if (!payload.comment) return { comments: others };
return {
comments: [...others, payload.comment].sort((a, b) =>
a.createdAt.localeCompare(b.createdAt),
),
};
}),
),
withEventHandlers(
(
store,
events = inject(Events),
issues = inject(IssuesGateway),
comments = inject(CommentsGateway),
activity = inject(ActivityGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
) => {
const fetchIssue = (idOrKey: string) =>
issues.issue(idOrKey).pipe(
retry({
count: 5,
delay: (error) =>
isNotFound(error) ? timer(300) : throwError(() => error),
}),
mapResponse({
next: (issue) => issueApiEvents.loaded(issue),
error: (error) => issueApiEvents.loadFailed(describeError(error)),
}),
);
const loaded$ = events.on(issueApiEvents.loaded);
const done = (message: string) =>
issueApiEvents.commandSucceeded(message);
const failed = issueApiEvents.commandFailed;
return {
load$: events
.on(issuePageEvents.opened)
.pipe(switchMap(({ payload }) => fetchIssue(payload.idOrKey))),
comments$: events.on(issuePageEvents.opened).pipe(
switchMap(() => loaded$.pipe(map(({ payload }) => payload.id))),
filter((id, index) => index === 0 || id !== store.issue()?.id),
switchMap((issueId) =>
comments
.list(issueId)
.pipe(map((list) => issueApiEvents.commentsLoaded(list))),
),
),
history$: loaded$.pipe(
debounceTime(100),
switchMap(({ payload }) =>
activity
.list(payload.projectId, payload.id)
.pipe(map((list) => issueApiEvents.historyLoaded(list))),
),
),
/** Any change to this ticket by anyone: refetch the full details. */
liveIssue$: events.on(issuePageEvents.opened).pipe(
switchMap(() => loaded$.pipe(map(({ payload }) => payload))),
filter(
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
),
switchMap((issue) =>
realtime.issueChanges(issue.projectId).pipe(
filter(
(change) =>
change.issue.id === issue.id &&
change.version > (store.issue()?.version ?? 0),
),
map((change) =>
issueRealtimeEvents.issueTouched({
issueId: issue.id,
version: change.version,
}),
),
),
),
),
refetch$: events.on(issueRealtimeEvents.issueTouched).pipe(
debounceTime(150),
switchMap(({ payload }) => fetchIssue(payload.issueId)),
),
liveComments$: events.on(issuePageEvents.opened).pipe(
switchMap(() => loaded$.pipe(map(({ payload }) => payload.id))),
filter((id, index) => index === 0 || id !== store.issue()?.id),
switchMap((issueId) => realtime.commentChanges(issueId)),
map((change) => issueRealtimeEvents.commentChanged(change)),
),
commands$: merge(
events
.on(issuePageEvents.statusChangeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.changeStatus(payload),
'Estado actualizado',
done,
failed,
),
),
),
events
.on(issuePageEvents.assignRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.assign(payload.issueId, payload.assigneeId),
payload.assigneeId ? 'Asignado' : 'Sin asignar',
done,
failed,
),
),
),
events
.on(issuePageEvents.updateRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.update(payload),
'Ticket actualizado',
done,
failed,
),
),
),
events
.on(issuePageEvents.linkRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.link(payload),
'Relación añadida',
done,
failed,
),
),
),
events.on(issuePageEvents.linkByKeyRequested).pipe(
mergeMap(({ payload }) =>
runCommand(
issues.issue(payload.targetKey.trim().toUpperCase()).pipe(
switchMap((target) =>
issues.link({
issueId: payload.issueId,
type: payload.type,
targetIssueId: target.id,
}),
),
),
`Relacionado con ${payload.targetKey.toUpperCase()}`,
done,
failed,
),
),
),
events
.on(issuePageEvents.unlinkRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.unlink(payload),
'Relación eliminada',
done,
failed,
),
),
),
events
.on(issuePageEvents.releaseChangeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.setRelease(payload.issueId, payload.releaseId),
'Release actualizada',
done,
failed,
),
),
),
events
.on(issuePageEvents.commentAddRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
comments.add(payload.issueId, payload.body),
'Comentario añadido',
done,
failed,
),
),
),
events
.on(issuePageEvents.commentEditRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
comments.edit(payload.commentId, payload.body),
'Comentario editado',
done,
failed,
),
),
),
events
.on(issuePageEvents.commentDeleteRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
comments.remove(payload.commentId),
'Comentario eliminado',
done,
failed,
),
),
),
),
feedback$: merge(
events
.on(issueApiEvents.commandSucceeded)
.pipe(tap(({ payload }) => notifications.success(payload))),
events
.on(issueApiEvents.commandFailed, issueApiEvents.loadFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
),
};
},
),
);

View File

@@ -0,0 +1,304 @@
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 { AuthFacade } from '@task-stream/web/identity/application';
import {
Activity,
ActivityGateway,
Project,
ProjectRole,
ProjectsGateway,
ProjectStats,
Release,
ReleasesGateway,
TrackerRealtimeGateway,
UserDirectoryEntry,
UsersGateway,
} from '@task-stream/web/tracker/domain';
import {
debounceTime,
map,
merge,
mergeMap,
retry,
switchMap,
tap,
throwError,
timer,
} from 'rxjs';
import {
projectApiEvents,
projectPageEvents,
projectRealtimeEvents,
} from '../events/project.events';
import { describeError, isNotFound, Notifications } from '../notifications';
import { runCommand } from './command';
interface ProjectState {
project: Project | null;
stats: ProjectStats | null;
releases: Release[];
activity: Activity[];
userResults: UserDirectoryEntry[];
loading: boolean;
error: string | null;
}
/** The open project: details, members, releases, stats and live activity. Scoped to the project route. */
export const ProjectStore = signalStore(
withState<ProjectState>({
project: null,
stats: null,
releases: [],
activity: [],
userResults: [],
loading: false,
error: null,
}),
withComputed(({ project }, auth = inject(AuthFacade)) => {
const myRole = computed(
() =>
project()?.members.find((m) => m.user.id === auth.user()?.id)?.role ??
null,
);
return {
myRole,
canManage: computed(
() => myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN,
),
canContribute: computed(
() => !!myRole() && myRole() !== ProjectRole.VIEWER,
),
members: computed(() => project()?.members ?? []),
};
}),
withReducer(
on(projectPageEvents.opened, () => ({
loading: true,
error: null,
project: null,
stats: null,
releases: [],
activity: [],
})),
on(
projectApiEvents.loaded,
projectRealtimeEvents.projectChanged,
({ payload }) => ({ project: payload, loading: false }),
),
on(projectApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
releases: payload,
})),
on(projectApiEvents.activityLoaded, ({ payload }) => ({
activity: payload,
})),
on(projectApiEvents.usersFound, ({ payload }) => ({
userResults: payload,
})),
on(projectRealtimeEvents.activityAdded, ({ payload }, state) => ({
activity: [
payload,
...state.activity.filter((a) => a.id !== payload.id),
].slice(0, 100),
})),
),
withEventHandlers(
(
store,
events = inject(Events),
projects = inject(ProjectsGateway),
releases = inject(ReleasesGateway),
activity = inject(ActivityGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
users = inject(UsersGateway),
) => {
const loaded$ = events.on(projectApiEvents.loaded);
const reloadReleases = (projectId: string) =>
releases.list(projectId).pipe(
mapResponse({
next: (list) => projectApiEvents.releasesLoaded(list),
error: (error) =>
projectApiEvents.commandFailed(describeError(error)),
}),
);
return {
load$: events.on(projectPageEvents.opened).pipe(
switchMap(({ payload }) =>
projects.project(payload.idOrKey).pipe(
// Just created: the read model may be a few ms behind.
retry({
count: 5,
delay: (error) =>
isNotFound(error) ? timer(300) : throwError(() => error),
}),
mapResponse({
next: (project) => projectApiEvents.loaded(project),
error: (error) =>
projectApiEvents.loadFailed(describeError(error)),
}),
),
),
),
details$: loaded$.pipe(
switchMap(({ payload }) =>
merge(
projects
.stats(payload.id)
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
reloadReleases(payload.id),
activity
.list(payload.id)
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
),
),
),
live$: loaded$.pipe(
switchMap(({ payload }) =>
merge(
realtime
.projectChanges(payload.id)
.pipe(map((p) => projectRealtimeEvents.projectChanged(p))),
realtime
.activity(payload.id)
.pipe(map((a) => projectRealtimeEvents.activityAdded(a))),
realtime
.releaseChanges(payload.id)
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
),
),
),
/** Activity means tickets moved: refresh the dashboard (debounced). */
refreshStats$: events.on(projectRealtimeEvents.activityAdded).pipe(
debounceTime(800),
switchMap(() => {
const id = store.project()?.id;
return id
? projects
.stats(id)
.pipe(map((stats) => projectApiEvents.statsLoaded(stats)))
: [];
}),
),
/** Progress is computed by the API: refetch the list when a release or its tickets change. */
refreshReleases$: merge(
events.on(projectRealtimeEvents.releaseChanged),
events
.on(projectRealtimeEvents.activityAdded)
.pipe(debounceTime(800)),
).pipe(
switchMap(() => {
const id = store.project()?.id;
return id ? reloadReleases(id) : [];
}),
),
/** Pireon directory search (to add members). */
searchUsers$: events.on(projectPageEvents.userSearchRequested).pipe(
debounceTime(250),
switchMap(({ payload }) =>
users.search(payload).pipe(
mapResponse({
next: (list) => projectApiEvents.usersFound(list),
error: () => projectApiEvents.usersFound([]),
}),
),
),
),
commands$: merge(
events
.on(projectPageEvents.memberAddRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
projects.addMember(payload),
'Miembro añadido',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.memberRoleChangeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
projects.changeMemberRole(payload),
'Rol actualizado',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.memberRemoveRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
projects.removeMember(payload.projectId, payload.userId),
'Miembro eliminado',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.releaseCreateRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
releases.create(payload),
`Release ${payload.name} creada`,
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.releasePublishRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
releases.publish(payload.releaseId, payload.force),
'Release publicada 🚀',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.releaseArchiveRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
releases.archive(payload.releaseId),
'Release archivada',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
),
feedback$: merge(
events
.on(projectApiEvents.commandSucceeded)
.pipe(tap(({ payload }) => notifications.success(payload))),
events
.on(projectApiEvents.commandFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
),
};
},
),
);

View File

@@ -0,0 +1,122 @@
import { computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import { AuthFacade } from '@task-stream/web/identity/application';
import {
Project,
ProjectsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { exhaustMap, map, switchMap, tap } from 'rxjs';
import {
projectsApiEvents,
projectsPageEvents,
projectsRealtimeEvents,
} from '../events/projects.events';
import { describeError, Notifications } from '../notifications';
interface ProjectsState {
projects: Project[];
loading: boolean;
creating: boolean;
error: string | null;
}
/** "My projects": the list plus live membership changes. */
export const ProjectsStore = signalStore(
{ providedIn: 'root' },
withState<ProjectsState>({
projects: [],
loading: false,
creating: false,
error: null,
}),
withComputed(({ projects }) => ({
sorted: computed(() =>
[...projects()].sort((a, b) => a.name.localeCompare(b.name)),
),
})),
withReducer(
on(projectsPageEvents.opened, () => ({ loading: true, error: null })),
on(projectsApiEvents.loaded, ({ payload }) => ({
projects: payload,
loading: false,
})),
on(projectsApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
on(projectsPageEvents.createRequested, () => ({ creating: true })),
on(projectsApiEvents.created, projectsApiEvents.createFailed, () => ({
creating: false,
})),
on(projectsRealtimeEvents.projectChanged, ({ payload }, state) => {
const others = state.projects.filter((p) => p.id !== payload.project.id);
return {
projects: payload.stillMember ? [...others, payload.project] : others,
};
}),
),
withEventHandlers(
(
_store,
events = inject(Events),
projects = inject(ProjectsGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
router = inject(Router),
auth = inject(AuthFacade),
) => ({
load$: events.on(projectsPageEvents.opened).pipe(
switchMap(() =>
projects.myProjects().pipe(
mapResponse({
next: (list) => projectsApiEvents.loaded(list),
error: (error) =>
projectsApiEvents.loadFailed(describeError(error)),
}),
),
),
),
/** One live subscription for the whole session. */
live$: events.on(projectsPageEvents.opened).pipe(
exhaustMap(() => realtime.myProjectsChanges()),
map((project) =>
projectsRealtimeEvents.projectChanged({
project,
stillMember: project.members.some(
(m) => m.user.id === auth.user()?.id,
),
}),
),
),
create$: events.on(projectsPageEvents.createRequested).pipe(
exhaustMap(({ payload }) =>
projects.create(payload).pipe(
mapResponse({
next: (result) => projectsApiEvents.created(result),
error: (error) =>
projectsApiEvents.createFailed(describeError(error)),
}),
),
),
),
createdFeedback$: events.on(projectsApiEvents.created).pipe(
tap(({ payload }) => {
notifications.success(`Proyecto ${payload.key} creado`);
void router.navigate(['/p', payload.key]);
}),
),
failureFeedback$: events
.on(projectsApiEvents.createFailed, projectsApiEvents.loadFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
}),
),
);