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,26 @@
# web-tracker-application
Estado del tracker (NgRx Signals con eventos) detrás de facades.
- **Import**: `@task-stream/web/tracker/application`
- **Capa**: `application` · **Tags**: `scope:web-tracker`, `type:application`
## Contenido
- Facades (lo que se exporta): `ProjectsFacade` (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
`provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`.
- Interno: stores `ProjectsStore`, `ProjectStore`, `BoardStore`, `IssueStore` y eventos por origen
(`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
- `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión).
- Puerto `Notifications`.
Patrón de eventos: [Cliente web](../../../../docs/04-cliente-web.md#42-estado-ngrx-signals-con-eventos).
## Comandos
```sh
npx nx lint web-tracker-application
npx nx test web-tracker-application # Vitest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ts',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ts',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "web-tracker-application",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/tracker/application/src",
"prefix": "ts",
"projectType": "library",
"tags": ["scope:web-tracker", "type:application"],
"targets": {
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "coverage/libs/web/tracker/application"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,5 @@
export * from './lib/notifications';
export * from './lib/facades/projects.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';
export * from './lib/facades/issue.facade';

View File

@@ -0,0 +1,43 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
BoardColumn,
BoardFilter,
CommandResult,
CreateIssueInput,
IssueChanged,
IssueStatus,
} from '@task-stream/web/tracker/domain';
export const boardPageEvents = eventGroup({
source: 'Board Page',
events: {
opened: type<BoardFilter>(),
filterChanged: type<BoardFilter>(),
/** Optimistic: the card moves immediately and comes back if the API refuses. */
issueMoved: type<{
issueId: string;
to: IssueStatus;
reason?: string | null;
}>(),
issueCreateRequested: type<CreateIssueInput>(),
},
});
export const boardApiEvents = eventGroup({
source: 'Board API',
events: {
loaded: type<BoardColumn[]>(),
loadFailed: type<string>(),
issueMoveFailed: type<string>(),
issueCreated: type<CommandResult>(),
issueCreateFailed: type<string>(),
},
});
export const boardRealtimeEvents = eventGroup({
source: 'Board Realtime',
events: {
issueChanged: type<IssueChanged>(),
},
});

View File

@@ -0,0 +1,57 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
Activity,
ChangeStatusInput,
Comment,
CommentChanged,
Issue,
LinkIssuesInput,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
export const issuePageEvents = eventGroup({
source: 'Issue Page',
events: {
opened: type<{ idOrKey: string }>(),
statusChangeRequested: type<ChangeStatusInput>(),
assignRequested: type<{ issueId: string; assigneeId: string | null }>(),
updateRequested: type<UpdateIssueInput>(),
linkRequested: type<LinkIssuesInput>(),
/** Relate by the other ticket's key (e.g. BE-3), resolved to its id first. */
linkByKeyRequested: type<{
issueId: string;
type: LinkIssuesInput['type'];
targetKey: string;
}>(),
unlinkRequested: type<LinkIssuesInput>(),
releaseChangeRequested: type<{
issueId: string;
releaseId: string | null;
}>(),
commentAddRequested: type<{ issueId: string; body: string }>(),
commentEditRequested: type<{ commentId: string; body: string }>(),
commentDeleteRequested: type<{ commentId: string }>(),
},
});
export const issueApiEvents = eventGroup({
source: 'Issue API',
events: {
loaded: type<Issue>(),
loadFailed: type<string>(),
commentsLoaded: type<Comment[]>(),
historyLoaded: type<Activity[]>(),
commandSucceeded: type<string>(),
commandFailed: type<string>(),
},
});
export const issueRealtimeEvents = eventGroup({
source: 'Issue Realtime',
events: {
/** Something about this ticket changed: refetch it. */
issueTouched: type<{ issueId: string; version: number }>(),
commentChanged: type<CommentChanged>(),
},
});

View File

@@ -0,0 +1,48 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
Activity,
CreateReleaseInput,
MemberInput,
Project,
ProjectStats,
Release,
UserDirectoryEntry,
} from '@task-stream/web/tracker/domain';
export const projectPageEvents = eventGroup({
source: 'Project Page',
events: {
opened: type<{ idOrKey: string }>(),
memberAddRequested: type<MemberInput>(),
memberRoleChangeRequested: type<MemberInput>(),
memberRemoveRequested: type<{ projectId: string; userId: string }>(),
releaseCreateRequested: type<CreateReleaseInput>(),
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(),
userSearchRequested: type<string>(),
},
});
export const projectApiEvents = eventGroup({
source: 'Project API',
events: {
loaded: type<Project>(),
loadFailed: type<string>(),
statsLoaded: type<ProjectStats>(),
releasesLoaded: type<Release[]>(),
activityLoaded: type<Activity[]>(),
commandSucceeded: type<string>(),
commandFailed: type<string>(),
usersFound: type<UserDirectoryEntry[]>(),
},
});
export const projectRealtimeEvents = eventGroup({
source: 'Project Realtime',
events: {
projectChanged: type<Project>(),
activityAdded: type<Activity>(),
releaseChanged: type<Release>(),
},
});

View File

@@ -0,0 +1,33 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
CommandResult,
CreateProjectInput,
Project,
} from '@task-stream/web/tracker/domain';
export const projectsPageEvents = eventGroup({
source: 'Projects Page',
events: {
opened: type<void>(),
createRequested: type<CreateProjectInput>(),
},
});
export const projectsApiEvents = eventGroup({
source: 'Projects API',
events: {
loaded: type<Project[]>(),
loadFailed: type<string>(),
created: type<CommandResult>(),
createFailed: type<string>(),
},
});
export const projectsRealtimeEvents = eventGroup({
source: 'Projects Realtime',
events: {
/** Joined, left or changed one of my projects. */
projectChanged: type<{ project: Project; stillMember: boolean }>(),
},
});

View File

@@ -0,0 +1,45 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
BoardFilter,
CreateIssueInput,
IssueStatus,
} from '@task-stream/web/tracker/domain';
import { boardPageEvents } from '../events/board.events';
import { BoardStore } from '../stores/board.store';
/** Kanban board of a project, live. */
@Injectable()
export class BoardFacade {
private readonly store = inject(BoardStore);
private readonly dispatch = injectDispatch(boardPageEvents);
readonly columns = this.store.columns;
readonly filter = this.store.filter;
/** Tickets on the board (all columns). */
readonly total = this.store.total;
readonly loading = this.store.loading;
readonly creating = this.store.creating;
readonly error = this.store.error;
/** Shows the board with this filter: opens it for a new project, refilters otherwise. */
show(filter: BoardFilter): void {
if (this.store.filter()?.projectId === filter.projectId)
this.dispatch.filterChanged(filter);
else this.dispatch.opened(filter);
}
/** Optimistic: the card moves at once and comes back if the API refuses. */
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
}
createIssue(input: CreateIssueInput): void {
this.dispatch.issueCreateRequested(input);
}
}
/** Store + facade of the board, for the board route's component. */
export function provideBoardFacade(): Provider[] {
return [BoardStore, BoardFacade];
}

View File

@@ -0,0 +1,159 @@
import { Component, inject, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Events } from '@ngrx/signals/events';
import { AuthFacade } from '@task-stream/web/identity/application';
import {
ActivityGateway,
CommentsGateway,
IssuesGateway,
IssueStatus,
Project,
ProjectRole,
ProjectsGateway,
ReleasesGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
import { NEVER, of } from 'rxjs';
import { boardPageEvents } from '../events/board.events';
import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
import { BoardFacade, provideBoardFacade } from './board.facade';
import { IssueFacade, provideIssueFacade } from './issue.facade';
import { ProjectFacade, provideProjectFacade } from './project.facade';
const ok = { id: 'x', key: null, aggregateVersion: 1 };
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
const issue = { id: 'i1', key: 'WEB-1', links: [] };
/** Every command succeeds, every query answers once, nothing arrives in real time. */
function gateways() {
return {
projects: {
project: vi.fn(() => of(project)),
stats: vi.fn(() => NEVER),
addMember: vi.fn(() => of(ok)),
},
issues: {
board: vi.fn(() => of([])),
issue: vi.fn(() => of(issue)),
changeStatus: vi.fn(() => of(ok)),
},
list: { list: vi.fn(() => of([])) },
realtime: {
issueChanges: () => NEVER,
commentChanges: () => NEVER,
activity: () => NEVER,
releaseChanges: () => NEVER,
projectChanges: () => NEVER,
},
};
}
/** Facades are scoped to a route component, like in the app. */
@Component({
template: '',
providers: [
provideProjectFacade(),
provideBoardFacade(),
provideIssueFacade(),
],
})
class Host {
readonly project = inject(ProjectFacade);
readonly board = inject(BoardFacade);
readonly issue = inject(IssueFacade);
}
function setup() {
const fakes = gateways();
TestBed.configureTestingModule({
providers: [
{ provide: ProjectsGateway, useValue: fakes.projects },
{ provide: IssuesGateway, useValue: fakes.issues },
{ provide: ReleasesGateway, useValue: fakes.list },
{ provide: ActivityGateway, useValue: fakes.list },
{ provide: CommentsGateway, useValue: fakes.list },
{ provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
},
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
],
});
const host = TestBed.createComponent(Host).componentInstance;
return { ...host, fakes };
}
describe('BoardFacade', () => {
it('opens the board for a new project and refilters the same one', () => {
const { board, fakes } = setup();
const seen: string[] = [];
TestBed.inject(Events)
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
.subscribe((event) => seen.push(event.type));
board.show({ projectId: 'p1' });
board.show({ projectId: 'p1', assigneeId: 'u2' });
board.show({ projectId: 'p2' });
expect(seen).toEqual([
boardPageEvents.opened.type,
boardPageEvents.filterChanged.type,
boardPageEvents.opened.type,
]);
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
expect(board.filter()).toEqual({ projectId: 'p2' });
});
});
describe('ProjectFacade', () => {
it('ignores commands until a project is open, then sends its id', () => {
const { project, fakes } = setup();
project.addMember('u2');
expect(fakes.projects.addMember).not.toHaveBeenCalled();
project.open('WEB');
expect(project.project()?.id).toBe('p1');
project.addMember('u2', ProjectRole.VIEWER);
expect(fakes.projects.addMember).toHaveBeenCalledWith({
projectId: 'p1',
userId: 'u2',
role: ProjectRole.VIEWER,
});
});
it('searches the directory from two characters', () => {
const { project } = setup();
const seen: string[] = [];
TestBed.inject(Events)
.on(projectPageEvents.userSearchRequested)
.subscribe(({ payload }) => seen.push(payload));
project.searchUsers(' a ');
project.searchUsers(' an ');
expect(seen).toEqual(['an']);
});
});
describe('IssueFacade', () => {
it('acts on the open ticket', () => {
const { issue, fakes } = setup();
issue.changeStatus(IssueStatus.IN_PROGRESS);
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
issue.open('WEB-1');
issue.changeStatus(IssueStatus.QA_FAILED, 'Falla en Safari');
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
issueId: 'i1',
status: IssueStatus.QA_FAILED,
reason: 'Falla en Safari',
});
});
});

View File

@@ -0,0 +1,101 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
IssueLink,
IssueLinkType,
IssueStatus,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { issuePageEvents } from '../events/issue.events';
import { IssueStore } from '../stores/issue.store';
/**
* One ticket for presenters: details, comments and history, live. Commands act
* on the open ticket, so pages don't pass its id.
*/
@Injectable()
export class IssueFacade {
private readonly store = inject(IssueStore);
private readonly dispatch = injectDispatch(issuePageEvents);
readonly issue = this.store.issue;
readonly comments = this.store.comments;
readonly history = this.store.history;
readonly outwardLinks = this.store.outwardLinks;
readonly inwardLinks = this.store.inwardLinks;
readonly loading = this.store.loading;
readonly error = this.store.error;
/** Signed-in user's id (authors can edit their comments). */
readonly me = this.store.me;
/** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */
open(idOrKey: string): void {
this.dispatch.opened({ idOrKey });
}
/** QA_FAILED and CANCELLED need a reason. */
changeStatus(status: IssueStatus, reason?: string | null): void {
this.withIssue((issueId) =>
this.dispatch.statusChangeRequested({ issueId, status, reason }),
);
}
assign(assigneeId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.assignRequested({ issueId, assigneeId }),
);
}
update(changes: Omit<UpdateIssueInput, 'issueId'>): void {
this.withIssue((issueId) =>
this.dispatch.updateRequested({ issueId, ...changes }),
);
}
/** Relates this ticket with another one by its key (e.g. BE-3). */
link(type: IssueLinkType, targetKey: string): void {
this.withIssue((issueId) =>
this.dispatch.linkByKeyRequested({ issueId, type, targetKey }),
);
}
unlink(link: IssueLink): void {
this.withIssue((issueId) =>
this.dispatch.unlinkRequested({
issueId,
type: link.type,
targetIssueId: link.issueId,
}),
);
}
setRelease(releaseId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.releaseChangeRequested({ issueId, releaseId }),
);
}
addComment(body: string): void {
this.withIssue((issueId) =>
this.dispatch.commentAddRequested({ issueId, body }),
);
}
editComment(commentId: string, body: string): void {
this.dispatch.commentEditRequested({ commentId, body });
}
deleteComment(commentId: string): void {
this.dispatch.commentDeleteRequested({ commentId });
}
private withIssue(command: (issueId: string) => void): void {
const issueId = this.store.issue()?.id;
if (issueId) command(issueId);
}
}
/** Store + facade of a ticket, for the issue route's component. */
export function provideIssueFacade(): Provider[] {
return [IssueStore, IssueFacade];
}

View File

@@ -0,0 +1,85 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
ProjectRole,
} from '@task-stream/web/tracker/domain';
import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store';
/**
* The open project for presenters: details, members, releases, stats and live
* activity. Commands act on the open project, so pages don't pass its id.
*/
@Injectable()
export class ProjectFacade {
private readonly store = inject(ProjectStore);
private readonly dispatch = injectDispatch(projectPageEvents);
readonly project = this.store.project;
readonly members = this.store.members;
readonly stats = this.store.stats;
readonly releases = this.store.releases;
readonly activity = this.store.activity;
/** Pireon directory results for `searchUsers()`. */
readonly userResults = this.store.userResults;
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly myRole = this.store.myRole;
/** Owner or admin. */
readonly canManage = this.store.canManage;
/** Any role but viewer. */
readonly canContribute = this.store.canContribute;
/** Loads the project (by id or key) and follows its changes. */
open(idOrKey: string): void {
this.dispatch.opened({ idOrKey });
}
/** Searches Pireon's directory (from 2 characters, debounced). */
searchUsers(term: string): void {
const query = term.trim();
if (query.length >= 2) this.dispatch.userSearchRequested(query);
}
addMember(userId: string, role: ProjectRole = ProjectRole.MEMBER): void {
const projectId = this.projectId();
if (projectId)
this.dispatch.memberAddRequested({ projectId, userId, role });
}
changeMemberRole(userId: string, role: ProjectRole): void {
const projectId = this.projectId();
if (projectId)
this.dispatch.memberRoleChangeRequested({ projectId, userId, role });
}
removeMember(userId: string): void {
const projectId = this.projectId();
if (projectId) this.dispatch.memberRemoveRequested({ projectId, userId });
}
createRelease(release: Omit<CreateReleaseInput, 'projectId'>): void {
const projectId = this.projectId();
if (projectId)
this.dispatch.releaseCreateRequested({ projectId, ...release });
}
/** `force` publishes even with unfinished tickets. */
publishRelease(releaseId: string, force = false): void {
this.dispatch.releasePublishRequested({ releaseId, force });
}
archiveRelease(releaseId: string): void {
this.dispatch.releaseArchiveRequested({ releaseId });
}
private projectId(): string | null {
return this.store.project()?.id ?? null;
}
}
/** Store + facade of the open project, for the project route's component. */
export function provideProjectFacade(): Provider[] {
return [ProjectStore, ProjectFacade];
}

View File

@@ -0,0 +1,29 @@
import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { CreateProjectInput } from '@task-stream/web/tracker/domain';
import { projectsPageEvents } from '../events/projects.events';
import { ProjectsStore } from '../stores/projects.store';
/** "My projects" for presenters: state as signals, intentions as methods. */
@Injectable({ providedIn: 'root' })
export class ProjectsFacade {
private readonly store = inject(ProjectsStore);
private readonly dispatch = injectDispatch(projectsPageEvents);
readonly projects = this.store.projects;
/** By name. */
readonly sorted = this.store.sorted;
readonly loading = this.store.loading;
readonly creating = this.store.creating;
readonly error = this.store.error;
/** Loads the list and starts following membership changes. */
load(): void {
this.dispatch.opened();
}
/** On success, notifies and navigates to the new project. */
create(input: CreateProjectInput): void {
this.dispatch.createRequested(input);
}
}

View File

@@ -0,0 +1,25 @@
/** Outbound port: tell the user how a command went (implemented with toasts by the app). */
export abstract class Notifications {
abstract success(message: string): void;
abstract error(message: string): void;
}
/** Message of a failed GraphQL/HTTP call, for the user. */
export function describeError(error: unknown): string {
if (
error &&
typeof error === 'object' &&
'errors' in error &&
Array.isArray((error as { errors: unknown[] }).errors)
) {
return (error as { errors: { message: string }[] }).errors
.map((e) => e.message)
.join('\n');
}
return error instanceof Error ? error.message : String(error);
}
/** The read model is eventually consistent: right after a command, a query may 404 for a few ms. */
export function isNotFound(error: unknown): boolean {
return /not found/i.test(describeError(error));
}

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

View File

@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed();

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,29 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: import.meta.dirname,
cacheDir: '../../../../node_modules/.vite/libs/web/tracker/application',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: () => [ nxViteTsPaths() ],
// },
test: {
name: 'web-tracker-application',
watch: false,
passWithNoTests: true,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../../coverage/libs/web/tracker/application',
provider: 'v8' as const,
},
},
}));

View File

@@ -0,0 +1,21 @@
# web-tracker-domain
Modelo del tracker en el cliente.
- **Import**: `@task-stream/web/tracker/domain`
- **Capa**: `domain` · **Tags**: `scope:web-tracker`, `type:domain`
## Contenido
- Modelos (`Project`, `IssueSummary`, `Issue`, `Release`…), enums e inputs.
- Workflow (`REASON_REQUIRED`, `BOARD_COLUMNS`).
- Puertos: `ProjectsGateway`, `IssuesGateway`, `CommentsGateway`, `ReleasesGateway`, `ActivityGateway`, `UsersGateway`, `TrackerRealtimeGateway`.
## Comandos
```sh
npx nx lint web-tracker-domain
npx nx test web-tracker-domain # Jest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: 'web-tracker-domain',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
passWithNoTests: true,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/web/tracker/domain',
};

View File

@@ -0,0 +1,9 @@
{
"name": "web-tracker-domain",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/tracker/domain/src",
"projectType": "library",
"tags": ["scope:web-tracker", "type:domain"],
"// targets": "to see all targets run: nx show project web-tracker-domain --web",
"targets": {}
}

View File

@@ -0,0 +1,5 @@
export * from './lib/models/enums';
export * from './lib/models/models';
export * from './lib/models/inputs';
export * from './lib/models/workflow';
export * from './lib/ports/gateways';

View File

@@ -0,0 +1,57 @@
export enum IssueType {
BUG = 'BUG',
FEATURE = 'FEATURE',
UI_CHANGE = 'UI_CHANGE',
TASK = 'TASK',
QA = 'QA',
}
export enum IssueArea {
FRONTEND = 'FRONTEND',
BACKEND = 'BACKEND',
MOBILE = 'MOBILE',
QA = 'QA',
DESIGN = 'DESIGN',
DEVOPS = 'DEVOPS',
}
export enum IssuePriority {
LOWEST = 'LOWEST',
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
export enum IssueStatus {
BACKLOG = 'BACKLOG',
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
IN_REVIEW = 'IN_REVIEW',
READY_FOR_QA = 'READY_FOR_QA',
IN_QA = 'IN_QA',
QA_FAILED = 'QA_FAILED',
DONE = 'DONE',
CANCELLED = 'CANCELLED',
}
export enum IssueLinkType {
BLOCKS = 'BLOCKS',
DEPENDS_ON = 'DEPENDS_ON',
TESTS = 'TESTS',
DUPLICATES = 'DUPLICATES',
RELATES_TO = 'RELATES_TO',
}
export enum ProjectRole {
OWNER = 'OWNER',
ADMIN = 'ADMIN',
MEMBER = 'MEMBER',
VIEWER = 'VIEWER',
}
export enum ReleaseStatus {
PLANNED = 'PLANNED',
RELEASED = 'RELEASED',
ARCHIVED = 'ARCHIVED',
}

View File

@@ -0,0 +1,65 @@
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
} from './enums';
export interface CreateProjectInput {
key: string;
name: string;
description?: string | null;
}
export interface CreateIssueInput {
projectId: string;
type: IssueType;
title: string;
description?: string | null;
area?: IssueArea | null;
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
assigneeId?: string | null;
releaseId?: string | null;
}
export type UpdateIssueInput = Partial<
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId'>
> & {
issueId: string;
};
export interface ChangeStatusInput {
issueId: string;
status: IssueStatus;
reason?: string | null;
}
export interface LinkIssuesInput {
issueId: string;
type: IssueLinkType;
targetIssueId: string;
}
export interface MemberInput {
projectId: string;
userId: string;
role: ProjectRole;
}
export interface CreateReleaseInput {
projectId: string;
name: string;
description?: string | null;
targetDate?: string | null;
}
export interface BoardFilter {
projectId: string;
assigneeId?: string;
releaseId?: string;
area?: IssueArea;
}

View File

@@ -0,0 +1,131 @@
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
} from './enums';
export interface UserRef {
id: string;
name: string;
}
export interface ProjectMember {
user: UserRef;
role: ProjectRole;
}
export interface Project {
id: string;
key: string;
name: string;
description: string | null;
owner: UserRef;
members: ProjectMember[];
updatedAt: string;
version: number;
}
export interface IssueLink {
type: IssueLinkType;
/** OUTWARD: this issue → other. INWARD: other → this issue. */
direction: 'OUTWARD' | 'INWARD';
issueId: string;
issueKey: string;
}
/** What boards and lists show. */
export interface IssueSummary {
id: string;
projectId: string;
key: string;
type: IssueType;
area: IssueArea | null;
title: string;
status: IssueStatus;
priority: IssuePriority;
assignee: UserRef | null;
release: { id: string; name: string } | null;
labels: string[];
commentCount: number;
updatedAt: string;
version: number;
}
export interface Issue extends IssueSummary {
description: string | null;
reporter: UserRef;
figmaUrl: string | null;
storyPoints: number | null;
dueDate: string | null;
links: IssueLink[];
allowedTransitions: IssueStatus[];
createdAt: string;
}
export interface Comment {
id: string;
issueId: string;
author: UserRef;
body: string;
createdAt: string;
editedAt: string | null;
}
export interface Release {
id: string;
projectId: string;
name: string;
description: string | null;
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
progress: { total: number; finished: number; percent: number };
}
export interface Activity {
id: string;
type: string;
issueId: string | null;
issueKey: string | null;
actor: UserRef;
summary: string;
occurredAt: string;
}
export interface CountBy<T = string> {
key: T;
count: number;
}
export interface ProjectStats {
total: number;
open: number;
byStatus: CountBy<IssueStatus>[];
byType: CountBy[];
byPriority: CountBy[];
byArea: CountBy[];
byAssignee: { user: UserRef | null; count: number }[];
}
export interface BoardColumn {
status: IssueStatus;
issues: IssueSummary[];
total: number;
}
export interface UserDirectoryEntry {
id: string;
name: string;
email: string;
}
/** Ack of a command: the read model catches up asynchronously. */
export interface CommandResult {
id: string;
key?: string | null;
version: number;
}

View File

@@ -0,0 +1,50 @@
import { IssuePriority, IssueStatus, IssueType } from './enums';
/** Statuses that require an explanation (the API rejects them without one). */
export const REASON_REQUIRED: readonly IssueStatus[] = [
IssueStatus.QA_FAILED,
IssueStatus.CANCELLED,
];
export const BOARD_COLUMNS: readonly IssueStatus[] = [
IssueStatus.BACKLOG,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.IN_REVIEW,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.QA_FAILED,
IssueStatus.DONE,
];
export const STATUS_LABEL: Record<IssueStatus, string> = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.TODO]: 'To do',
[IssueStatus.IN_PROGRESS]: 'In progress',
[IssueStatus.IN_REVIEW]: 'In review',
[IssueStatus.READY_FOR_QA]: 'Ready for QA',
[IssueStatus.IN_QA]: 'In QA',
[IssueStatus.QA_FAILED]: 'QA failed',
[IssueStatus.DONE]: 'Done',
[IssueStatus.CANCELLED]: 'Cancelled',
};
export const TYPE_LABEL: Record<IssueType, string> = {
[IssueType.BUG]: 'Bug',
[IssueType.FEATURE]: 'Feature',
[IssueType.UI_CHANGE]: 'UI change',
[IssueType.TASK]: 'Task',
[IssueType.QA]: 'QA',
};
export const PRIORITY_ORDER: readonly IssuePriority[] = [
IssuePriority.CRITICAL,
IssuePriority.HIGH,
IssuePriority.MEDIUM,
IssuePriority.LOW,
IssuePriority.LOWEST,
];
export function isFinished(status: IssueStatus): boolean {
return status === IssueStatus.DONE || status === IssueStatus.CANCELLED;
}

View File

@@ -0,0 +1,108 @@
import { Observable } from 'rxjs';
import {
Activity,
BoardColumn,
Comment,
CommandResult,
Issue,
IssueSummary,
Project,
ProjectStats,
Release,
UserDirectoryEntry,
} from '../models/models';
import {
BoardFilter,
ChangeStatusInput,
CreateIssueInput,
CreateProjectInput,
CreateReleaseInput,
LinkIssuesInput,
MemberInput,
UpdateIssueInput,
} from '../models/inputs';
/*
* Outbound ports of the tracker. The application layer depends on these
* abstract classes; the infrastructure layer implements them over GraphQL.
*/
export abstract class ProjectsGateway {
abstract myProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>;
abstract create(input: CreateProjectInput): Observable<CommandResult>;
abstract addMember(input: MemberInput): Observable<CommandResult>;
abstract changeMemberRole(input: MemberInput): Observable<CommandResult>;
abstract removeMember(
projectId: string,
userId: string,
): Observable<CommandResult>;
}
export abstract class IssuesGateway {
abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
abstract issue(idOrKey: string): Observable<Issue>;
abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
abstract create(input: CreateIssueInput): Observable<CommandResult>;
abstract update(input: UpdateIssueInput): Observable<CommandResult>;
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
abstract assign(
issueId: string,
assigneeId: string | null,
): Observable<CommandResult>;
abstract link(input: LinkIssuesInput): Observable<CommandResult>;
abstract unlink(input: LinkIssuesInput): Observable<CommandResult>;
abstract setRelease(
issueId: string,
releaseId: string | null,
): Observable<CommandResult>;
}
export abstract class CommentsGateway {
abstract list(issueId: string): Observable<Comment[]>;
abstract add(issueId: string, body: string): Observable<CommandResult>;
abstract edit(commentId: string, body: string): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
}
export abstract class ReleasesGateway {
abstract list(projectId: string): Observable<Release[]>;
abstract create(input: CreateReleaseInput): Observable<CommandResult>;
abstract publish(
releaseId: string,
force: boolean,
): Observable<CommandResult>;
abstract archive(releaseId: string): Observable<CommandResult>;
}
export abstract class ActivityGateway {
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
}
export abstract class UsersGateway {
abstract search(term: string): Observable<UserDirectoryEntry[]>;
}
/** Realtime (GraphQL subscriptions over WebSocket). */
export interface IssueChanged {
action: string;
version: number;
actorId: string;
issue: IssueSummary;
}
export interface CommentChanged {
action: string;
commentId: string;
comment: Comment | null;
}
export abstract class TrackerRealtimeGateway {
abstract issueChanges(projectId: string): Observable<IssueChanged>;
abstract commentChanges(issueId: string): Observable<CommentChanged>;
abstract activity(projectId: string): Observable<Activity>;
abstract releaseChanges(projectId: string): Observable<Release>;
abstract projectChanges(projectId: string): Observable<Project>;
abstract myProjectsChanges(): Observable<Project>;
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "bundler",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,20 @@
# web-tracker-infrastructure
Adaptadores GraphQL del tracker.
- **Import**: `@task-stream/web/tracker/infrastructure`
- **Capa**: `infrastructure` · **Tags**: `scope:web-tracker`, `type:infrastructure`
## Contenido
- `graphql/*.graphql`: operaciones; `generated.ts` lo genera `npx nx run web:codegen`.
- `adapters/`: implementaciones de los gateways y `mappers.ts` (GraphQL → dominio).
- `provideTrackerInfrastructure()`.
## Comandos
```sh
npx nx lint web-tracker-infrastructure
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ts',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ts',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,13 @@
{
"name": "web-tracker-infrastructure",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/tracker/infrastructure/src",
"prefix": "ts",
"projectType": "library",
"tags": ["scope:web-tracker", "type:infrastructure"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/tracker-infrastructure.providers';

View File

@@ -0,0 +1,108 @@
import { inject, Injectable } from '@angular/core';
import {
Activity,
ActivityGateway,
CommandResult,
Comment,
CommentsGateway,
CreateReleaseInput,
Release,
ReleasesGateway,
UserDirectoryEntry,
UsersGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
ActivityDocument,
AddCommentDocument,
ArchiveReleaseDocument,
CreateReleaseDocument,
DeleteCommentDocument,
EditCommentDocument,
IssueCommentsDocument,
PublishReleaseDocument,
ReleasesDocument,
SearchUsersDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toActivity, toCommandResult, toComment, toRelease } from './mappers';
@Injectable()
export class GraphqlCommentsGateway extends CommentsGateway {
private readonly gql = inject(GraphqlOperations);
list(issueId: string): Observable<Comment[]> {
return this.gql
.query(IssueCommentsDocument, { idOrKey: issueId })
.pipe(map((d) => d.issue.comments.items.map(toComment)));
}
add(issueId: string, body: string): Observable<CommandResult> {
return this.gql
.mutate(AddCommentDocument, { issueId, body })
.pipe(map((d) => toCommandResult(d.addComment)));
}
edit(commentId: string, body: string): Observable<CommandResult> {
return this.gql
.mutate(EditCommentDocument, { commentId, body })
.pipe(map((d) => toCommandResult(d.editComment)));
}
remove(commentId: string): Observable<CommandResult> {
return this.gql
.mutate(DeleteCommentDocument, { commentId })
.pipe(map((d) => toCommandResult(d.deleteComment)));
}
}
@Injectable()
export class GraphqlReleasesGateway extends ReleasesGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<Release[]> {
return this.gql
.query(ReleasesDocument, { idOrKey: projectId })
.pipe(map((d) => d.project.releases.map(toRelease)));
}
create(input: CreateReleaseInput): Observable<CommandResult> {
return this.gql
.mutate(CreateReleaseDocument, { input })
.pipe(map((d) => toCommandResult(d.createRelease)));
}
publish(releaseId: string, force: boolean): Observable<CommandResult> {
return this.gql
.mutate(PublishReleaseDocument, { releaseId, force })
.pipe(map((d) => toCommandResult(d.publishRelease)));
}
archive(releaseId: string): Observable<CommandResult> {
return this.gql
.mutate(ArchiveReleaseDocument, { releaseId })
.pipe(map((d) => toCommandResult(d.archiveRelease)));
}
}
@Injectable()
export class GraphqlActivityGateway extends ActivityGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string, issueId?: string): Observable<Activity[]> {
return this.gql
.query(ActivityDocument, { projectId, issueId: issueId ?? null })
.pipe(map((d) => d.activity.map(toActivity)));
}
}
@Injectable()
export class GraphqlUsersGateway extends UsersGateway {
private readonly gql = inject(GraphqlOperations);
search(term: string): Observable<UserDirectoryEntry[]> {
return this.gql
.query(SearchUsersDocument, { search: term })
.pipe(map((d) => d.users));
}
}

View File

@@ -0,0 +1,112 @@
import { inject, Injectable } from '@angular/core';
import {
BoardColumn,
BoardFilter,
ChangeStatusInput,
CommandResult,
CreateIssueInput,
Issue,
IssuesGateway,
IssueSummary,
LinkIssuesInput,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AssignIssueDocument,
BoardDocument,
ChangeIssueStatusDocument,
CreateIssueDocument,
IssueDocument,
LinkIssuesDocument,
SearchIssuesDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toIssue, toIssueSummary } from './mappers';
@Injectable()
export class GraphqlIssuesGateway extends IssuesGateway {
private readonly gql = inject(GraphqlOperations);
board(filter: BoardFilter): Observable<BoardColumn[]> {
return this.gql
.query(BoardDocument, {
idOrKey: filter.projectId,
releaseId: filter.releaseId ?? null,
assigneeId: filter.assigneeId ?? null,
area: filter.area ?? null,
})
.pipe(
map((d) =>
d.project.board.map((column) => ({
status: column.status as BoardColumn['status'],
total: column.total,
issues: column.issues.map(toIssueSummary),
})),
),
);
}
issue(idOrKey: string): Observable<Issue> {
return this.gql
.query(IssueDocument, { idOrKey })
.pipe(map((d) => toIssue(d.issue)));
}
search(projectId: string, text: string): Observable<IssueSummary[]> {
return this.gql
.query(SearchIssuesDocument, { filter: { projectId, search: text } })
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
}
create(input: CreateIssueInput): Observable<CommandResult> {
return this.gql
.mutate(CreateIssueDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.createIssue)));
}
update(input: UpdateIssueInput): Observable<CommandResult> {
return this.gql
.mutate(UpdateIssueDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.updateIssue)));
}
changeStatus(input: ChangeStatusInput): Observable<CommandResult> {
return this.gql
.mutate(ChangeIssueStatusDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.changeIssueStatus)));
}
assign(
issueId: string,
assigneeId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(AssignIssueDocument, { issueId, assigneeId })
.pipe(map((d) => toCommandResult(d.assignIssue)));
}
link(input: LinkIssuesInput): Observable<CommandResult> {
return this.gql
.mutate(LinkIssuesDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.linkIssues)));
}
unlink(input: LinkIssuesInput): Observable<CommandResult> {
return this.gql
.mutate(UnlinkIssuesDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.unlinkIssues)));
}
setRelease(
issueId: string,
releaseId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(SetIssueReleaseDocument, { issueId, releaseId })
.pipe(map((d) => toCommandResult(d.setIssueRelease)));
}
}

View File

@@ -0,0 +1,46 @@
import { inject, Injectable } from '@angular/core';
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { Apollo } from 'apollo-angular';
import { filter, map, Observable } from 'rxjs';
type Vars = Record<string, unknown>;
/** Thin typed wrapper: one-shot queries/mutations and subscriptions as plain data streams. */
@Injectable({ providedIn: 'root' })
export class GraphqlOperations {
private readonly apollo = inject(Apollo);
query<TData, TVars extends Vars>(
document: TypedDocumentNode<TData, TVars>,
variables?: TVars,
): Observable<TData> {
return this.apollo
.query<TData, TVars>({ query: document, variables: variables as TVars })
.pipe(map((result) => result.data as TData));
}
mutate<TData, TVars extends Vars>(
document: TypedDocumentNode<TData, TVars>,
variables: TVars,
): Observable<TData> {
return this.apollo
.mutate<TData, TVars>({ mutation: document, variables })
.pipe(map((result) => result.data as TData));
}
subscribe<TData, TVars extends Vars>(
document: TypedDocumentNode<TData, TVars>,
variables?: TVars,
): Observable<TData> {
return this.apollo
.subscribe<
TData,
TVars
>({ query: document, variables: variables as TVars })
.pipe(
map((result) => result.data),
filter((data): data is NonNullable<typeof data> => !!data),
map((data) => data as TData),
);
}
}

View File

@@ -0,0 +1,68 @@
import { inject, Injectable } from '@angular/core';
import {
CommandResult,
CreateProjectInput,
MemberInput,
Project,
ProjectsGateway,
ProjectStats,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AddProjectMemberDocument,
ChangeProjectMemberRoleDocument,
CreateProjectDocument,
MyProjectsDocument,
ProjectDocument,
ProjectStatsDocument,
RemoveProjectMemberDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toProject, toStats } from './mappers';
@Injectable()
export class GraphqlProjectsGateway extends ProjectsGateway {
private readonly gql = inject(GraphqlOperations);
myProjects(): Observable<Project[]> {
return this.gql
.query(MyProjectsDocument)
.pipe(map((d) => d.myProjects.map(toProject)));
}
project(idOrKey: string): Observable<Project> {
return this.gql
.query(ProjectDocument, { idOrKey })
.pipe(map((d) => toProject(d.project)));
}
stats(projectId: string): Observable<ProjectStats> {
return this.gql
.query(ProjectStatsDocument, { idOrKey: projectId })
.pipe(map((d) => toStats(d.project.stats)));
}
create(input: CreateProjectInput): Observable<CommandResult> {
return this.gql
.mutate(CreateProjectDocument, { input })
.pipe(map((d) => toCommandResult(d.createProject)));
}
addMember(input: MemberInput): Observable<CommandResult> {
return this.gql
.mutate(AddProjectMemberDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.addProjectMember)));
}
changeMemberRole(input: MemberInput): Observable<CommandResult> {
return this.gql
.mutate(ChangeProjectMemberRoleDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.changeProjectMemberRole)));
}
removeMember(projectId: string, userId: string): Observable<CommandResult> {
return this.gql
.mutate(RemoveProjectMemberDocument, { projectId, userId })
.pipe(map((d) => toCommandResult(d.removeProjectMember)));
}
}

View File

@@ -0,0 +1,77 @@
import { inject, Injectable } from '@angular/core';
import {
Activity,
CommentChanged,
IssueChanged,
Project,
Release,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
ActivityAddedDocument,
CommentChangedDocument,
IssueChangedDocument,
MyProjectsChangedDocument,
ProjectChangedDocument,
ReleaseChangedDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import {
toActivity,
toComment,
toIssueSummary,
toProject,
toRelease,
} from './mappers';
/** GraphQL subscriptions (graphql-ws). Each stream lives while it is subscribed. */
@Injectable()
export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
private readonly gql = inject(GraphqlOperations);
issueChanges(projectId: string): Observable<IssueChanged> {
return this.gql.subscribe(IssueChangedDocument, { projectId }).pipe(
map(({ issueChanged }) => ({
...issueChanged,
issue: toIssueSummary(issueChanged.issue),
})),
);
}
commentChanges(issueId: string): Observable<CommentChanged> {
return this.gql.subscribe(CommentChangedDocument, { issueId }).pipe(
map(({ commentChanged }) => ({
action: commentChanged.action,
commentId: commentChanged.commentId,
comment: commentChanged.comment
? toComment(commentChanged.comment)
: null,
})),
);
}
activity(projectId: string): Observable<Activity> {
return this.gql
.subscribe(ActivityAddedDocument, { projectId })
.pipe(map((d) => toActivity(d.activityAdded)));
}
releaseChanges(projectId: string): Observable<Release> {
return this.gql
.subscribe(ReleaseChangedDocument, { projectId })
.pipe(map((d) => toRelease(d.releaseChanged.release)));
}
projectChanges(projectId: string): Observable<Project> {
return this.gql
.subscribe(ProjectChangedDocument, { projectId })
.pipe(map((d) => toProject(d.projectChanged.project)));
}
myProjectsChanges(): Observable<Project> {
return this.gql
.subscribe(MyProjectsChangedDocument)
.pipe(map((d) => toProject(d.myProjectsChanged.project)));
}
}

View File

@@ -0,0 +1,55 @@
import type {
Activity,
Comment,
CommandResult,
Issue,
IssueSummary,
Project,
ProjectStats,
Release,
} from '@task-stream/web/tracker/domain';
import type {
ActivityFieldsFragment,
CommandResultFieldsFragment,
CommentFieldsFragment,
IssueFieldsFragment,
IssueSummaryFieldsFragment,
ProjectFieldsFragment,
ProjectStatsQuery,
ReleaseFieldsFragment,
} from '../graphql/generated';
/*
* GraphQL → domain. The generated types already have the domain's shape;
* enums arrive as string unions with the same values as the domain enums,
* so mapping is a typed re-labelling kept in one place.
*/
const as = <T>(value: unknown): T => value as T;
export const toProject = (p: ProjectFieldsFragment): Project => as<Project>(p);
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
as<IssueSummary>(i);
export const toIssue = (i: IssueFieldsFragment): Issue => as<Issue>(i);
export const toComment = (c: CommentFieldsFragment): Comment => c;
export const toActivity = (a: ActivityFieldsFragment): Activity => a;
export const toCommandResult = (
r: CommandResultFieldsFragment,
): CommandResult => r;
export function toRelease(
r: ReleaseFieldsFragment & {
progress?: { total: number; finished: number; percent: number };
},
): Release {
return as<Release>({
...r,
progress: r.progress ?? { total: 0, finished: 0, percent: 0 },
});
}
export function toStats(
stats: NonNullable<ProjectStatsQuery['project']>['stats'],
): ProjectStats {
return as<ProjectStats>(stats);
}

View File

@@ -0,0 +1,102 @@
fragment UserRefFields on UserRef {
id
name
}
fragment ProjectFields on Project {
id
key
name
description
updatedAt
version
owner {
...UserRefFields
}
members {
role
user {
...UserRefFields
}
}
}
fragment IssueSummaryFields on Issue {
id
projectId
key
type
area
title
status
priority
labels
commentCount
updatedAt
version
assignee {
...UserRefFields
}
release {
id
name
}
}
fragment IssueFields on Issue {
...IssueSummaryFields
description
figmaUrl
storyPoints
dueDate
createdAt
allowedTransitions
reporter {
...UserRefFields
}
links {
type
direction
issueId
issueKey
}
}
fragment CommentFields on Comment {
id
issueId
body
createdAt
editedAt
author {
...UserRefFields
}
}
fragment ReleaseFields on Release {
id
projectId
name
description
targetDate
status
releasedAt
}
fragment ActivityFields on Activity {
id
type
issueId
issueKey
summary
occurredAt
actor {
...UserRefFields
}
}
fragment CommandResultFields on CommandResult {
id
key
version
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
query Board(
$idOrKey: String!
$releaseId: ID
$assigneeId: ID
$area: IssueArea
) {
project(idOrKey: $idOrKey) {
id
board(
releaseId: $releaseId
assigneeId: $assigneeId
area: $area
perColumn: 100
) {
status
total
issues {
...IssueSummaryFields
}
}
}
}
query Issue($idOrKey: String!) {
issue(idOrKey: $idOrKey) {
...IssueFields
}
}
query SearchIssues($filter: IssueFilterInput!) {
issues(filter: $filter, limit: 20) {
items {
...IssueSummaryFields
}
}
}
mutation CreateIssue($input: CreateIssueInput!) {
createIssue(input: $input) {
...CommandResultFields
}
}
mutation UpdateIssue($input: UpdateIssueInput!) {
updateIssue(input: $input) {
...CommandResultFields
}
}
mutation ChangeIssueStatus($input: ChangeIssueStatusInput!) {
changeIssueStatus(input: $input) {
...CommandResultFields
}
}
mutation AssignIssue($issueId: ID!, $assigneeId: ID) {
assignIssue(issueId: $issueId, assigneeId: $assigneeId) {
...CommandResultFields
}
}
mutation LinkIssues($input: LinkIssuesInput!) {
linkIssues(input: $input) {
...CommandResultFields
}
}
mutation UnlinkIssues($input: LinkIssuesInput!) {
unlinkIssues(input: $input) {
...CommandResultFields
}
}
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
...CommandResultFields
}
}
query IssueComments($idOrKey: String!) {
issue(idOrKey: $idOrKey) {
id
comments(limit: 200) {
items {
...CommentFields
}
}
}
}
mutation AddComment($issueId: ID!, $body: String!) {
addComment(issueId: $issueId, body: $body) {
...CommandResultFields
}
}
mutation EditComment($commentId: ID!, $body: String!) {
editComment(commentId: $commentId, body: $body) {
...CommandResultFields
}
}
mutation DeleteComment($commentId: ID!) {
deleteComment(commentId: $commentId) {
...CommandResultFields
}
}

View File

@@ -0,0 +1,75 @@
query MyProjects {
myProjects {
...ProjectFields
}
}
query Project($idOrKey: String!) {
project(idOrKey: $idOrKey) {
...ProjectFields
}
}
query ProjectStats($idOrKey: String!) {
project(idOrKey: $idOrKey) {
id
stats {
total
open
byStatus {
key
count
}
byType {
key
count
}
byPriority {
key
count
}
byArea {
key
count
}
byAssignee {
count
user {
...UserRefFields
}
}
}
}
}
mutation CreateProject($input: CreateProjectInput!) {
createProject(input: $input) {
...CommandResultFields
}
}
mutation AddProjectMember($input: ProjectMemberInput!) {
addProjectMember(input: $input) {
...CommandResultFields
}
}
mutation ChangeProjectMemberRole($input: ProjectMemberInput!) {
changeProjectMemberRole(input: $input) {
...CommandResultFields
}
}
mutation RemoveProjectMember($projectId: ID!, $userId: ID!) {
removeProjectMember(projectId: $projectId, userId: $userId) {
...CommandResultFields
}
}
query SearchUsers($search: String!) {
users(search: $search, limit: 20) {
id
name
email
}
}

View File

@@ -0,0 +1,37 @@
query Releases($idOrKey: String!) {
project(idOrKey: $idOrKey) {
id
releases {
...ReleaseFields
progress {
total
finished
percent
}
}
}
}
mutation CreateRelease($input: CreateReleaseInput!) {
createRelease(input: $input) {
...CommandResultFields
}
}
mutation PublishRelease($releaseId: ID!, $force: Boolean!) {
publishRelease(releaseId: $releaseId, force: $force) {
...CommandResultFields
}
}
mutation ArchiveRelease($releaseId: ID!) {
archiveRelease(releaseId: $releaseId) {
...CommandResultFields
}
}
query Activity($projectId: ID!, $issueId: ID) {
activity(projectId: $projectId, issueId: $issueId, limit: 100) {
...ActivityFields
}
}

View File

@@ -0,0 +1,50 @@
subscription IssueChanged($projectId: ID!) {
issueChanged(projectId: $projectId) {
action
version
actorId
issue {
...IssueSummaryFields
}
}
}
subscription CommentChanged($issueId: ID!) {
commentChanged(issueId: $issueId) {
action
commentId
comment {
...CommentFields
}
}
}
subscription ActivityAdded($projectId: ID!) {
activityAdded(projectId: $projectId) {
...ActivityFields
}
}
subscription ReleaseChanged($projectId: ID!) {
releaseChanged(projectId: $projectId) {
release {
...ReleaseFields
}
}
}
subscription ProjectChanged($projectId: ID!) {
projectChanged(projectId: $projectId) {
project {
...ProjectFields
}
}
}
subscription MyProjectsChanged {
myProjectsChanged {
project {
...ProjectFields
}
}
}

View File

@@ -0,0 +1,35 @@
import {
makeEnvironmentProviders,
type EnvironmentProviders,
} from '@angular/core';
import {
ActivityGateway,
CommentsGateway,
IssuesGateway,
ProjectsGateway,
ReleasesGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
import {
GraphqlActivityGateway,
GraphqlCommentsGateway,
GraphqlReleasesGateway,
GraphqlUsersGateway,
} from './adapters/graphql-collaboration.gateways';
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
/** Ports → GraphQL adapters. */
export function provideTrackerInfrastructure(): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: ProjectsGateway, useClass: GraphqlProjectsGateway },
{ provide: IssuesGateway, useClass: GraphqlIssuesGateway },
{ provide: CommentsGateway, useClass: GraphqlCommentsGateway },
{ provide: ReleasesGateway, useClass: GraphqlReleasesGateway },
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
]);
}

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,20 @@
# web-tracker-presenters
Pantallas del tracker con `@pi/ui`.
- **Import**: `@task-stream/web/tracker/presenters`
- **Capa**: `presenters` · **Tags**: `scope:web-tracker`, `type:presenters`
## Contenido
- Rutas `trackerRoutes`: proyectos, tablero kanban (drag & drop), ticket, releases, seguimiento, actividad y miembros.
- `ui/`: `IssueCardComponent`, `AvatarsComponent` y `copy.ts` (textos, iconos y tonos de color por estado, tipo,
prioridad y proyecto).
## Comandos
```sh
npx nx lint web-tracker-presenters
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ts',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ts',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,13 @@
{
"name": "web-tracker-presenters",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/tracker/presenters/src",
"prefix": "ts",
"projectType": "library",
"tags": ["scope:web-tracker", "type:presenters"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/tracker.routes';

View File

@@ -0,0 +1,161 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiEmptyStateComponent } from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { clock, initials, timeAgo } from '../ui/copy';
/** Live feed of everything that happens in the project, as a log. */
@Component({
selector: 'ts-activity-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiEmptyStateComponent],
template: `
<div class="ts-log-head">
<p class="ts-eyebrow"><span class="ts-live"></span> En directo</p>
<span class="ts-muted ts-mono"
>{{ facade.activity().length }} eventos</span
>
</div>
@if (facade.activity().length) {
<ol class="ts-log ts-panel" aria-live="polite">
@for (entry of facade.activity(); track entry.id) {
<li>
<time class="ts-log__time" [attr.datetime]="entry.occurredAt">{{
clock(entry.occurredAt)
}}</time>
<span class="ts-log__who" [attr.title]="entry.actor.name">{{
initialsOf(entry.actor.name)
}}</span>
<span class="ts-log__what">{{ entry.summary }}</span>
@if (entry.issueKey) {
<a class="ts-key" [routerLink]="['../issues', entry.issueKey]">{{
entry.issueKey
}}</a>
} @else {
<span></span>
}
<span class="ts-log__ago">{{ ago(entry.occurredAt) }}</span>
</li>
}
</ol>
} @else {
<pi-empty-state icon="activity" title="Sin actividad todavía" />
}
`,
styles: `
.ts-log-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
.ts-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.ts-muted {
font-size: 0.75rem;
}
}
.ts-live {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ts-s-done);
animation: ts-pulse 2s ease-out infinite;
}
@keyframes ts-pulse {
0% {
box-shadow: 0 0 0 0
color-mix(in srgb, var(--ts-s-done) 60%, transparent);
}
70%,
100% {
box-shadow: 0 0 0 0.5rem transparent;
}
}
.ts-log {
margin: 0;
padding: 0.25rem 0;
list-style: none;
li {
display: grid;
grid-template-columns: 3.5rem 1.75rem minmax(0, 1fr) auto 8.5rem;
align-items: center;
gap: 0.875rem;
margin: 0;
padding: 0.625rem 1.125rem;
border-bottom: 1px dashed var(--ts-line);
animation: ts-in 0.35s ease-out;
&:last-child {
border-bottom: 0;
}
&:first-child {
animation-name: ts-in, ts-flash;
animation-duration: 0.35s, 1.6s;
}
}
}
.ts-log__time,
.ts-log__ago {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-log__ago {
text-align: right;
white-space: nowrap;
}
.ts-log__who {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
font-family: var(--ts-font-mono);
font-size: 0.625rem;
font-weight: 600;
}
.ts-log__what {
font-size: 0.875rem;
color: var(--ts-ink-soft);
}
a.ts-key {
text-decoration: none;
&:hover {
color: var(--ts-brand);
border-color: var(--ts-brand);
}
}
@keyframes ts-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes ts-flash {
from {
background: color-mix(in srgb, var(--ts-brand) 10%, transparent);
}
}
@media (max-width: 640px) {
.ts-log li {
grid-template-columns: 1.75rem minmax(0, 1fr) auto;
gap: 0.625rem;
padding-inline: 0.875rem;
}
.ts-log__time,
.ts-log__ago {
display: none;
}
}
`,
})
export class ActivityPage {
protected readonly facade = inject(ProjectFacade);
protected readonly ago = timeAgo;
protected readonly initialsOf = initials;
protected readonly clock = clock;
}

View File

@@ -0,0 +1,125 @@
.ts-board-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.ts-filter {
width: 13rem;
@media (max-width: 640px) {
width: 100%;
}
}
.ts-board-total {
font-size: 0.875rem;
color: var(--ts-muted);
strong {
color: var(--ts-ink);
font-weight: 600;
}
}
.ts-spacer {
flex: 1;
}
// One lane per status, tinted with the status colour.
.ts-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(17rem, 1fr);
gap: 0.875rem;
overflow-x: auto;
padding-bottom: 0.75rem;
scroll-snap-type: x proximity;
}
.ts-column {
--tone: var(--ts-muted);
display: flex;
flex-direction: column;
min-height: 62vh;
border-radius: 1.125rem;
background: color-mix(in srgb, var(--tone) 5%, var(--ts-sunken));
border: 1px solid color-mix(in srgb, var(--tone) 18%, var(--ts-line));
scroll-snap-align: start;
}
.ts-column__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0.875rem 0.875rem 0.625rem;
}
.ts-column__name {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: color-mix(in srgb, var(--tone) 75%, var(--ts-ink));
&::before {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--tone);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--tone) 18%, transparent);
}
}
.ts-column__count {
font-family: var(--ts-font-mono);
font-size: 1.125rem;
font-weight: 500;
color: var(--ts-ink-soft);
}
.ts-column__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
padding: 0.125rem 0.625rem 0.75rem;
}
.ts-column__empty {
margin: 1.25rem 0;
text-align: center;
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: color-mix(in srgb, var(--ts-muted) 70%, transparent);
}
// CDK drag & drop
.cdk-drag-preview {
box-shadow: 0 18px 36px -14px rgb(0 0 0 / 45%);
border-radius: 0.625rem;
transform: rotate(1.5deg);
}
.cdk-drag-placeholder {
opacity: 0.35;
}
.cdk-drag-animating,
.ts-column__list.cdk-drop-list-dragging
ts-issue-card:not(.cdk-drag-placeholder) {
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
.ts-column__list.cdk-drop-list-receiving {
outline: 2px dashed color-mix(in srgb, var(--tone) 55%, transparent);
outline-offset: -4px;
border-radius: 0.625rem;
}

View File

@@ -0,0 +1,374 @@
import {
CdkDrag,
CdkDragDrop,
CdkDropList,
CdkDropListGroup,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { form, FormField, maxLength, required } from '@angular/forms/signals';
import {
PiActionButtonComponent,
PiLabelDirective,
PiSegmentedControlComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
type SegmentedControlOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
BoardFacade,
ProjectFacade,
provideBoardFacade,
} from '@task-stream/web/tracker/application';
import {
BoardColumn,
IssueArea,
IssuePriority,
IssueStatus,
IssueSummary,
IssueType,
REASON_REQUIRED,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card';
import { AREA, options, PRIORITY, STATUS, TYPE } from '../ui/copy';
const ALL = '';
@Component({
selector: 'ts-board-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CdkDropListGroup,
CdkDropList,
CdkDrag,
FormField,
IssueCardComponent,
PiActionButtonComponent,
PiSelectFieldComponent,
PiSegmentedControlComponent,
PiSkeletonComponent,
PiTextFieldComponent,
PiTextareaComponent,
PiLabelDirective,
],
providers: [provideBoardFacade()],
styleUrl: './board.page.scss',
template: `
<div class="ts-board-toolbar">
<pi-select-field
class="ts-filter"
placeholder="Persona"
[options]="assigneeOptions()"
[(value)]="assignee"
/>
<pi-select-field
class="ts-filter"
placeholder="Release"
[options]="releaseOptions()"
[(value)]="releaseFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Área"
[options]="areaOptions"
[(value)]="area"
/>
<span class="ts-board-total"
><strong class="ts-mono">{{ board.total() }}</strong> tickets</span
>
<span class="ts-spacer"></span>
@if (project.canContribute()) {
<pi-action-button
color="primary"
text="Nuevo ticket"
(execute)="openCreate()"
/>
}
</div>
@if (board.loading() && !board.columns().length) {
<div class="ts-board">
@for (i of [1, 2, 3, 4]; track i) {
<pi-skeleton shape="block" height="20rem" />
}
</div>
} @else {
<div class="ts-board" cdkDropListGroup>
@for (column of board.columns(); track column.status) {
<section
class="ts-column"
[attr.data-status]="column.status"
[style.--tone]="statusTone(column.status)"
>
<header class="ts-column__header">
<span class="ts-column__name">{{
statusLabel(column.status)
}}</span>
<span class="ts-column__count">{{ column.total }}</span>
</header>
<div
class="ts-column__list"
cdkDropList
[id]="column.status"
[cdkDropListData]="column"
[cdkDropListDisabled]="!project.canContribute()"
(cdkDropListDropped)="drop($event)"
>
@for (issue of column.issues; track issue.id) {
<ts-issue-card cdkDrag [cdkDragData]="issue" [issue]="issue" />
} @empty {
<p class="ts-column__empty">Nada por aquí</p>
}
</div>
</section>
}
</div>
}
<ng-template #createForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); create()"
novalidate
>
<pi-segmented-control
ariaLabel="Tipo"
[options]="typeOptions"
[(value)]="newType"
/>
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
<pi-textarea
[formField]="f.description"
placeholder="Pasos para reproducir, criterios de aceptación…"
>
<pi-label>Descripción</pi-label>
</pi-textarea>
@if (newType() === 'UI_CHANGE') {
<pi-text-field
[formField]="f.figmaUrl"
placeholder="https://www.figma.com/design/…"
>
<pi-label>Frame de Figma</pi-label>
</pi-text-field>
}
<div class="ts-form__row">
<pi-select-field [formField]="f.priority" [options]="priorityOptions">
<pi-label>Prioridad</pi-label>
</pi-select-field>
<pi-select-field [formField]="f.area" [options]="areaOptions">
<pi-label>Área</pi-label>
</pi-select-field>
</div>
<div class="ts-form__row">
<pi-select-field
[formField]="f.assigneeId"
[options]="assigneeOptions()"
>
<pi-label>Asignar a</pi-label>
</pi-select-field>
<pi-select-field
[formField]="f.releaseId"
[options]="releaseOptions()"
>
<pi-label>Release</pi-label>
</pi-select-field>
</div>
<pi-action-button
size="w100"
color="primary"
[text]="board.creating() ? 'Creando…' : 'Crear ticket'"
[disabled]="f().invalid() || board.creating()"
(execute)="create()"
/>
</form>
</ng-template>
<ng-template #reasonForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); confirmMove()"
novalidate
>
<p class="ts-muted">
Explica el motivo: quedará en el historial del ticket.
</p>
<pi-textarea
[formField]="reason.text"
placeholder="Sigue fallando en Safari 17…"
>
<pi-label [isRequired]="true">Motivo</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
text="Mover"
[disabled]="reason().invalid()"
(execute)="confirmMove()"
/>
</form>
</ng-template>
`,
})
export class BoardPage {
/** `?release=` (from the releases page), bound by the router. */
readonly release = input<string | undefined>();
protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm');
/* Filters */
protected readonly assignee = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly area = signal<string | null>(ALL);
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: ALL, label: 'Todas las personas' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: ALL, label: 'Todas las releases' },
...this.project
.releases()
.filter((r) => r.status === ReleaseStatus.PLANNED)
.map((r) => ({ value: r.id, label: r.name })),
],
);
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
{ value: ALL, label: 'Todas las áreas' },
...options(AREA),
];
protected readonly priorityOptions = options(PRIORITY);
protected readonly typeOptions: SegmentedControlOption<IssueType>[] =
options(TYPE);
/* New ticket */
protected readonly newType = signal<IssueType>(IssueType.BUG);
private readonly model = signal({
title: '',
description: '',
figmaUrl: '',
priority: IssuePriority.MEDIUM as string,
area: ALL as string,
assigneeId: ALL as string,
releaseId: ALL as string,
});
protected readonly f = form(this.model, (p) => {
required(p.title);
maxLength(p.title, 255);
});
protected readonly titlePlaceholder = computed(() =>
this.newType() === IssueType.BUG
? 'El botón de pagar no responde en Safari'
: 'Qué hay que hacer',
);
/* Move needing a reason */
private pendingMove: { issueId: string; to: IssueStatus } | null = null;
private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
constructor() {
effect(() => {
const release = this.release();
if (release) untracked(() => this.releaseFilter.set(release));
});
// (Re)load whenever the project or a filter changes.
effect(() => {
const projectId = this.project.project()?.id;
const filter = {
assigneeId: this.assignee() || undefined,
releaseId: this.releaseFilter() || undefined,
area: (this.area() || undefined) as IssueArea | undefined,
};
if (!projectId) return;
untracked(() => this.board.show({ projectId, ...filter }));
});
}
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
protected drop(
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
): void {
if (event.previousContainer === event.container) return;
const to = event.container.data.status;
const issueId = event.item.data.id;
if (REASON_REQUIRED.includes(to)) {
this.pendingMove = { issueId, to };
this.reasonModel.set({ text: '' });
this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm());
return;
}
this.board.moveIssue(issueId, to);
}
protected confirmMove(): void {
if (!this.pendingMove || this.reason().invalid()) return;
const { issueId, to } = this.pendingMove;
this.board.moveIssue(issueId, to, this.reasonModel().text);
this.pendingMove = null;
this.drawer.closeAll();
}
protected openCreate(): void {
this.model.set({
title: '',
description: '',
figmaUrl: '',
priority: IssuePriority.MEDIUM,
area: ALL,
assigneeId: ALL,
releaseId: this.releaseFilter() || ALL,
});
this.drawer.open('Nuevo ticket', this.createForm(), '36rem');
}
protected create(): void {
const projectId = this.project.project()?.id;
if (!projectId || this.f().invalid()) return;
const v = this.model();
this.board.createIssue({
projectId,
type: this.newType(),
title: v.title,
description: v.description || null,
figmaUrl:
this.newType() === IssueType.UI_CHANGE && v.figmaUrl
? v.figmaUrl
: null,
priority: v.priority as IssuePriority,
area: (v.area || null) as IssueArea | null,
assigneeId: v.assigneeId || null,
releaseId: v.releaseId || null,
});
this.drawer.closeAll();
}
}

View File

@@ -0,0 +1,335 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
BOARD_COLUMNS,
IssueStatus,
IssueType,
} from '@task-stream/web/tracker/domain';
import { initials, STATUS, TYPE } from '../ui/copy';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
selector: 'ts-dashboard-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiSkeletonComponent, PiStatCardComponent],
template: `
@if (facade.stats(); as stats) {
<section class="ts-kpis" aria-label="Resumen">
@for (kpi of kpis(); track kpi.label) {
<pi-stat-card
[icon]="kpi.icon"
[label]="kpi.label"
[value]="'' + kpi.value"
[color]="kpi.color"
/>
}
</section>
<section class="ts-panel ts-flow" aria-labelledby="flow-title">
<header class="ts-panel__head">
<h2 id="flow-title">Flujo</h2>
<span class="ts-muted">Dónde está cada ticket del proyecto</span>
</header>
@if (stats.total) {
<div class="ts-flow__bar" role="img" [attr.aria-label]="flowLabel()">
@for (segment of flow(); track segment.status) {
@if (segment.count) {
<span
class="ts-flow__segment"
[style.--tone]="segment.tone"
[style.flex-grow]="segment.count"
[attr.title]="segment.label + ': ' + segment.count"
></span>
}
}
</div>
<ul class="ts-flow__legend">
@for (segment of flow(); track segment.status) {
<li
[style.--tone]="segment.tone"
[class.is-empty]="!segment.count"
>
<span class="ts-flow__count">{{ segment.count }}</span>
<span>{{ segment.label }}</span>
</li>
}
</ul>
} @else {
<p class="ts-muted">Todavía no hay tickets.</p>
}
</section>
<div class="ts-dashboard">
<section class="ts-panel" aria-labelledby="load-title">
<header class="ts-panel__head">
<h2 id="load-title">¿Quién está con qué?</h2>
<span class="ts-muted">Tickets abiertos</span>
</header>
<ul class="ts-rows">
@for (row of stats.byAssignee; track row.user?.id ?? 'none') {
<li>
@if (row.user; as user) {
<span class="ts-who">{{ initialsOf(user.name) }}</span>
<span class="ts-rows__name">{{ user.name }}</span>
} @else {
<span class="ts-who ts-who--none">—</span>
<span class="ts-rows__name ts-muted">Sin asignar</span>
}
<span class="ts-bar"
><span [style.width.%]="(row.count / maxLoad()) * 100"></span
></span>
<span class="ts-rows__count">{{ row.count }}</span>
</li>
} @empty {
<li class="ts-muted">Nada abierto.</li>
}
</ul>
</section>
<section class="ts-panel" aria-labelledby="type-title">
<header class="ts-panel__head">
<h2 id="type-title">Por tipo</h2>
<span class="ts-muted">Todos los tickets</span>
</header>
<ul class="ts-rows ts-rows--types">
@for (row of stats.byType; track row.key) {
<li [style.--tone]="typeOf(row.key).tone">
<span class="ts-rows__name">{{ typeOf(row.key).label }}</span>
<span class="ts-bar ts-bar--tone"
><span
[style.width.%]="(row.count / stats.total) * 100"
></span
></span>
<span class="ts-rows__count">{{ row.count }}</span>
</li>
}
</ul>
</section>
</div>
} @else {
<pi-skeleton shape="block" height="12rem" />
}
`,
styles: `
/* Same stat row as pireon.dev's hero. */
.ts-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1.25rem;
margin-bottom: 2rem;
}
.ts-panel {
padding: 1.5rem;
}
.ts-panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.125rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.ts-muted {
font-size: 0.8125rem;
}
}
.ts-flow {
margin-bottom: 1.25rem;
}
.ts-flow__bar {
display: flex;
gap: 3px;
height: 2.25rem;
}
.ts-flow__segment {
flex-basis: 0;
min-width: 0.5rem;
border-radius: 0.375rem;
background: var(--tone);
}
.ts-flow__legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: baseline;
gap: 0.4375rem;
margin: 0;
font-size: 0.8125rem;
color: var(--ts-ink-soft);
&::before {
content: '';
align-self: center;
width: 0.5rem;
height: 0.5rem;
border-radius: 2px;
background: var(--tone);
}
}
li.is-empty {
opacity: 0.45;
}
}
.ts-flow__count {
font-family: var(--ts-font-mono);
font-weight: 600;
color: var(--ts-ink);
}
.ts-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
gap: 1.25rem;
}
.ts-rows {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
grid-template-columns: 1.75rem minmax(6rem, 9rem) 1fr 2rem;
align-items: center;
gap: 0.625rem;
margin: 0;
font-size: 0.875rem;
}
}
.ts-rows--types li {
grid-template-columns: minmax(6rem, 9rem) 1fr 2rem;
}
.ts-rows__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-rows__count {
font-family: var(--ts-font-mono);
font-weight: 600;
text-align: right;
}
.ts-who {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
font-family: var(--ts-font-mono);
font-size: 0.625rem;
font-weight: 600;
}
.ts-who--none {
background: transparent;
border: 1px dashed var(--ts-line-strong);
color: var(--ts-muted);
}
.ts-bar {
height: 0.625rem;
border-radius: 0.25rem;
background: var(--ts-sunken);
overflow: hidden;
span {
display: block;
height: 100%;
border-radius: 0.25rem;
background: var(--ts-brand);
}
}
.ts-bar--tone span {
background: var(--tone);
}
`,
})
export class DashboardPage {
protected readonly facade = inject(ProjectFacade);
protected readonly initialsOf = initials;
private count(status: IssueStatus): number {
return (
this.facade.stats()?.byStatus.find((s) => s.key === status)?.count ?? 0
);
}
protected readonly kpis = computed<
{
label: string;
value: number;
icon: string;
color: 'primary' | 'accent' | 'warn' | 'highlighted' | 'success';
}[]
>(() => {
const stats = this.facade.stats();
if (!stats) return [];
const bugs = stats.byType.find((t) => t.key === IssueType.BUG)?.count ?? 0;
return [
{ label: 'Tickets', value: stats.total, icon: 'list', color: 'primary' },
{
label: 'Abiertos',
value: stats.open,
icon: 'time-detail',
color: 'accent',
},
{ label: 'Bugs (total)', value: bugs, icon: 'bug', color: 'warn' },
{
label: 'Pendientes de QA',
value:
this.count(IssueStatus.READY_FOR_QA) + this.count(IssueStatus.IN_QA),
icon: 'qa',
color: 'highlighted',
},
{
label: 'QA fallidos',
value: this.count(IssueStatus.QA_FAILED),
icon: 'exclamation',
color: 'warn',
},
{
label: 'Hechos',
value: this.count(IssueStatus.DONE),
icon: 'check-rounded',
color: 'success',
},
];
});
/** Statuses in workflow order (cancelled at the end). */
protected readonly flow = computed(() =>
[...BOARD_COLUMNS, IssueStatus.CANCELLED].map((status) => ({
status,
label: STATUS[status].label,
tone: STATUS[status].tone,
count: this.count(status),
})),
);
protected readonly flowLabel = computed(() =>
this.flow()
.filter((s) => s.count)
.map((s) => `${s.label}: ${s.count}`)
.join(', '),
);
protected readonly maxLoad = computed(() =>
Math.max(
1,
...(this.facade.stats()?.byAssignee.map((r) => r.count) ?? [1]),
),
);
protected readonly typeOf = (key: string) =>
TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' };
}

View File

@@ -0,0 +1,340 @@
.ts-back {
display: inline-block;
margin-bottom: 1.25rem;
font-family: var(--ts-font-mono);
font-size: 0.8125rem;
color: var(--ts-muted);
text-decoration: none;
&:hover {
color: var(--ts-ink);
}
}
.ts-issue {
display: grid;
grid-template-columns: minmax(0, 1fr) 20rem;
gap: 2.5rem;
align-items: start;
@media (max-width: 960px) {
grid-template-columns: minmax(0, 1fr);
}
}
.ts-issue__main {
display: grid;
gap: 2rem;
min-width: 0;
}
// Header: key · type · status, then the title.
.ts-issue__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.ts-type {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--tone);
}
.ts-issue__title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
h1 {
margin: 0.75rem 0 0;
font-size: clamp(1.625rem, 1.2rem + 1.4vw, 2.375rem);
font-weight: 700;
line-height: 1.1;
text-wrap: balance;
}
}
// Workflow: one button per allowed destination, in that status' colour.
.ts-transitions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 0.875rem;
border: 1px dashed var(--ts-line-strong);
border-radius: 0.75rem;
.ts-eyebrow {
margin-right: 0.25rem;
}
}
.ts-move {
--tone: var(--ts-muted);
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4375rem 0.75rem 0.4375rem 0.625rem;
border: 1px solid color-mix(in srgb, var(--tone) 35%, var(--ts-line));
border-radius: 999px;
background: var(--ts-surface);
color: var(--ts-ink);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&::before {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--tone);
}
span {
color: var(--tone);
transition: transform 0.15s ease;
}
&:hover {
background: color-mix(in srgb, var(--tone) 10%, var(--ts-surface));
border-color: var(--tone);
span {
transform: translateX(2px);
}
}
&:focus-visible {
outline: 2px solid var(--tone);
outline-offset: 2px;
}
}
// Sections: small uppercase label over a rule (pireon.dev's eyebrow, in grey).
.ts-section {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.875rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--ts-line);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ts-muted);
.ts-mono {
color: var(--ts-ink-soft);
}
}
.ts-block > p {
margin: 0;
line-height: 1.6;
}
.ts-figma {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.75rem;
padding: 0.375rem 0.75rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-t-ui) 10%, var(--ts-surface));
color: color-mix(in srgb, var(--ts-t-ui) 80%, var(--ts-ink));
font-size: 0.8125rem;
font-weight: 600;
text-decoration: none;
}
.ts-link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.375rem 0;
}
.ts-link__kind {
min-width: 9rem;
font-size: 0.875rem;
color: var(--ts-muted);
}
a.ts-key {
text-decoration: none;
&:hover {
border-color: var(--ts-brand);
color: var(--ts-brand);
}
}
.ts-inline-form {
display: grid;
grid-template-columns: 12rem minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: center;
margin-top: 0.875rem;
@media (max-width: 640px) {
grid-template-columns: minmax(0, 1fr);
}
}
// Comments: a thread with initials on a rail.
.ts-comment {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr);
gap: 0.75rem;
padding: 0.875rem 0;
border-bottom: 1px solid var(--ts-line);
}
.ts-comment__avatar {
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
font-family: var(--ts-font-mono);
font-size: 0.6875rem;
font-weight: 600;
}
.ts-comment__body {
min-width: 0;
p {
margin: 0.25rem 0 0;
line-height: 1.55;
}
}
.ts-comment__head {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2rem;
}
.ts-comment__when {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-spacer {
flex: 1;
}
// History as a log: time · what · how long ago.
.ts-log {
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
grid-template-columns: 3.75rem minmax(0, 1fr) auto;
gap: 0.875rem;
align-items: baseline;
padding: 0.5rem 0;
border-bottom: 1px dashed var(--ts-line);
font-size: 0.875rem;
&:last-child {
border-bottom: 0;
}
}
}
.ts-log__when,
.ts-log__ago {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-log__what {
color: var(--ts-ink-soft);
}
@media (max-width: 640px) {
.ts-log li {
grid-template-columns: 3.25rem minmax(0, 1fr);
}
.ts-log__ago {
display: none;
}
}
// Side: the ticket's sheet.
.ts-issue__side {
display: grid;
gap: 1.375rem;
> .ts-eyebrow {
margin-bottom: 0.125rem;
}
position: sticky;
top: 5.5rem;
padding: 1.5rem;
@media (max-width: 960px) {
position: static;
}
}
.ts-facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.625rem 1rem;
margin: 0.25rem 0 0;
padding-top: 1rem;
border-top: 1px dashed var(--ts-line);
font-size: 0.875rem;
dt {
font-family: var(--ts-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ts-muted);
align-self: center;
}
dd {
margin: 0;
}
.ts-mono {
font-size: 0.8125rem;
}
}
.ts-labels {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
}
.ts-label {
font-family: var(--ts-font-mono);
font-size: 0.8125rem;
color: var(--ts-brand);
}

View File

@@ -0,0 +1,615 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiIconButtonComponent,
PiIconComponent,
PiLabelDirective,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
IssueFacade,
ProjectFacade,
provideIssueFacade,
} from '@task-stream/web/tracker/application';
import {
Comment,
IssueArea,
IssueLink,
IssueLinkType,
IssuePriority,
IssueStatus,
REASON_REQUIRED,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import {
AREA,
clock,
initials,
LINK,
options,
PRIORITY,
STATUS,
TYPE,
timeAgo,
} from '../ui/copy';
const NONE = '';
@Component({
selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
FormField,
PiActionButtonComponent,
PiIconComponent,
PiIconButtonComponent,
PiLabelDirective,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
PiTextareaComponent,
],
providers: [provideIssueFacade()],
styleUrl: './issue.page.scss',
template: `
<a class="ts-back" routerLink="../../board">← Tablero</a>
@if (facade.issue(); as issue) {
<div class="ts-issue">
<section class="ts-issue__main">
<header class="ts-issue__header">
<div class="ts-issue__meta">
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-type" [style.--tone]="type().tone">
<pi-icon [icon]="type().icon" size="x-small" />
{{ type().label }}
</span>
<span class="ts-pill" [style.--tone]="status().tone">{{
status().label
}}</span>
</div>
<div class="ts-issue__title-row">
<h1>{{ issue.title }}</h1>
@if (project.canContribute()) {
<pi-icon-button
icon="edit"
color="grey"
appearance="ghost"
aria-label="Editar"
(execute)="openEdit()"
/>
}
</div>
</header>
@if (project.canContribute() && issue.allowedTransitions.length) {
<div class="ts-transitions" role="group" aria-label="Mover a">
<span class="ts-eyebrow">Mover a</span>
@for (to of issue.allowedTransitions; track to) {
<button
type="button"
class="ts-move"
[style.--tone]="statusTone(to)"
(click)="move(to)"
>
{{ statusLabel(to) }}
<span aria-hidden="true">→</span>
</button>
}
</div>
}
<article class="ts-block">
<h2 class="ts-section">Descripción</h2>
@if (issue.description) {
<p class="ts-prewrap">{{ issue.description }}</p>
} @else {
<p class="ts-muted">Sin descripción.</p>
}
@if (issue.figmaUrl) {
<a
class="ts-figma"
[href]="issue.figmaUrl"
target="_blank"
rel="noopener"
>
<pi-icon icon="figma" size="small" /> Ver el diseño en Figma
</a>
}
</article>
<article class="ts-block">
<h2 class="ts-section">Relaciones</h2>
@for (
link of facade.outwardLinks();
track link.issueId + link.type
) {
<div class="ts-link">
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
link.issueKey
}}</a>
@if (project.canContribute()) {
<pi-icon-button
icon="close"
color="grey"
appearance="ghost"
aria-label="Quitar"
(execute)="unlink(link)"
/>
}
</div>
}
@for (
link of facade.inwardLinks();
track link.issueId + link.type
) {
<div class="ts-link">
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
link.issueKey
}}</a>
</div>
}
@if (!issue.links.length) {
<p class="ts-muted">
Sin relaciones. Vincula FE ↔ BE ↔ QA para seguir el trabajo de
punta a punta.
</p>
}
@if (project.canContribute()) {
<form
class="ts-inline-form"
(submit)="$event.preventDefault(); link()"
novalidate
>
<pi-select-field
[options]="linkTypeOptions"
[(value)]="linkType"
/>
<pi-text-field
placeholder="Clave, p. ej. BE-3"
[(value)]="linkTarget"
/>
<pi-action-button
size="small"
color="primary"
text="Relacionar"
[disabled]="!linkTarget().trim()"
(execute)="link()"
/>
</form>
}
</article>
<article class="ts-block">
<h2 class="ts-section">
Comentarios
<span class="ts-mono">{{ facade.comments().length }}</span>
</h2>
@for (comment of facade.comments(); track comment.id) {
<div class="ts-comment">
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
}}</span>
<div class="ts-comment__body">
<div class="ts-comment__head">
<strong>{{ comment.author.name }}</strong>
<span class="ts-comment__when"
>{{ ago(comment.createdAt)
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
<span class="ts-spacer"></span>
@if (comment.author.id === facade.me()) {
<pi-icon-button
icon="edit"
color="grey"
appearance="ghost"
aria-label="Editar"
(execute)="openEditComment(comment)"
/>
}
@if (
comment.author.id === facade.me() || project.canManage()
) {
<pi-icon-button
icon="trash"
color="grey"
appearance="ghost"
aria-label="Eliminar"
(execute)="deleteComment(comment)"
/>
}
</div>
<p class="ts-prewrap">{{ comment.body }}</p>
</div>
</div>
}
@if (project.canContribute()) {
<form
class="ts-form"
(submit)="$event.preventDefault(); addComment()"
novalidate
>
<pi-textarea
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
[(value)]="commentBody"
/>
<div>
<pi-action-button
color="primary"
text="Comentar"
[disabled]="!commentBody().trim()"
(execute)="addComment()"
/>
</div>
</form>
}
</article>
<article class="ts-block">
<h2 class="ts-section">Historial</h2>
<ol class="ts-log">
@for (entry of facade.history(); track entry.id) {
<li>
<time
class="ts-log__when"
[attr.datetime]="entry.occurredAt"
>{{ clock(entry.occurredAt) }}</time
>
<span class="ts-log__what">{{ entry.summary }}</span>
<span class="ts-log__ago">{{ ago(entry.occurredAt) }}</span>
</li>
}
</ol>
</article>
</section>
<aside class="ts-issue__side ts-panel">
<p class="ts-eyebrow">Ficha</p>
<pi-select-field
[options]="assigneeOptions()"
[disabled]="!project.canContribute()"
[value]="issue.assignee?.id ?? ''"
(valueChange)="assign($event)"
>
<pi-label>Asignado</pi-label>
</pi-select-field>
<pi-select-field
[options]="releaseOptions()"
[disabled]="!project.canContribute()"
[value]="issue.release?.id ?? ''"
(valueChange)="setRelease($event)"
>
<pi-label>Release</pi-label>
</pi-select-field>
<pi-select-field
[options]="priorityOptions"
[disabled]="!project.canContribute()"
[value]="issue.priority"
(valueChange)="update({ priority: $any($event) })"
>
<pi-label>Prioridad</pi-label>
</pi-select-field>
<pi-select-field
[options]="areaOptions"
[disabled]="!project.canContribute()"
[value]="issue.area ?? ''"
(valueChange)="update({ area: $any($event || null) })"
>
<pi-label>Área</pi-label>
</pi-select-field>
<dl class="ts-facts">
<dt>Reportado por</dt>
<dd>{{ issue.reporter.name }}</dd>
<dt>Creado</dt>
<dd class="ts-mono">{{ ago(issue.createdAt) }}</dd>
<dt>Actualizado</dt>
<dd class="ts-mono">{{ ago(issue.updatedAt) }}</dd>
@if (issue.labels.length) {
<dt>Etiquetas</dt>
<dd class="ts-labels">
@for (label of issue.labels; track label) {
<span class="ts-label">#{{ label }}</span>
}
</dd>
}
</dl>
</aside>
</div>
} @else if (facade.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo abrir el ticket"
[text]="error"
/>
} @else {
<pi-skeleton shape="text" [lines]="8" />
}
<ng-template #reasonForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); confirmMove()"
novalidate
>
<p class="ts-muted">
Explica el motivo: quedará en el historial del ticket.
</p>
<pi-textarea
[formField]="reason.text"
placeholder="Qué ha fallado y cómo reproducirlo"
>
<pi-label [isRequired]="true">Motivo</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
text="Mover"
[disabled]="reason().invalid()"
(execute)="confirmMove()"
/>
</form>
</ng-template>
<ng-template #editForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveEdit()"
novalidate
>
<pi-text-field [formField]="edit.title">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
<pi-textarea [formField]="edit.description">
<pi-label>Descripción</pi-label>
</pi-textarea>
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
<pi-label>Etiquetas (separadas por comas)</pi-label>
</pi-text-field>
<pi-text-field
[formField]="edit.figmaUrl"
placeholder="https://www.figma.com/design/…"
>
<pi-label>Frame de Figma</pi-label>
</pi-text-field>
<pi-action-button
size="w100"
color="primary"
text="Guardar"
[disabled]="edit().invalid()"
(execute)="saveEdit()"
/>
</form>
</ng-template>
<ng-template #commentEditForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveComment()"
novalidate
>
<pi-textarea [formField]="commentEdit.body" />
<pi-action-button
size="w100"
color="primary"
text="Guardar"
[disabled]="commentEdit().invalid()"
(execute)="saveComment()"
/>
</form>
</ng-template>
`,
})
export class IssuePage {
/** Route param (`withComponentInputBinding`). */
readonly issueKey = input.required<string>();
protected readonly facade = inject(IssueFacade);
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly editForm =
viewChild.required<TemplateRef<unknown>>('editForm');
private readonly commentEditForm =
viewChild.required<TemplateRef<unknown>>('commentEditForm');
protected readonly type = computed(
() => TYPE[this.facade.issue()?.type ?? 'TASK'],
);
protected readonly status = computed(
() => STATUS[this.facade.issue()?.status ?? IssueStatus.BACKLOG],
);
protected readonly ago = timeAgo;
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NONE, label: 'Sin asignar' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NONE, label: 'Sin release' },
...this.project
.releases()
.filter(
(r) =>
r.status === ReleaseStatus.PLANNED ||
r.id === this.facade.issue()?.release?.id,
)
.map((r) => ({ value: r.id, label: r.name })),
],
);
protected readonly priorityOptions = options(PRIORITY);
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
{ value: NONE, label: 'Sin área' },
...options(AREA),
];
protected readonly linkTypeOptions = (
Object.keys(LINK) as IssueLinkType[]
).map((value) => ({
value,
label: LINK[value].outward,
}));
// Inline forms: plain signals (a required validator would flag them red right after submitting).
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
protected readonly linkTarget = signal('');
protected readonly commentBody = signal('');
private pendingStatus: IssueStatus | null = null;
private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
private readonly editModel = signal({
title: '',
description: '',
labels: '',
figmaUrl: '',
});
protected readonly edit = form(this.editModel, (p) => required(p.title));
private editingComment: string | null = null;
private readonly commentEditModel = signal({ body: '' });
protected readonly commentEdit = form(this.commentEditModel, (p) =>
required(p.body),
);
constructor() {
effect(() => this.facade.open(this.issueKey()));
}
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
protected readonly initialsOf = initials;
protected readonly clock = clock;
protected linkLabel = (link: IssueLink) =>
link.direction === 'OUTWARD'
? LINK[link.type].outward
: LINK[link.type].inward;
protected move(to: IssueStatus): void {
if (REASON_REQUIRED.includes(to)) {
this.pendingStatus = to;
this.reasonModel.set({ text: '' });
this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm());
return;
}
this.facade.changeStatus(to);
}
protected confirmMove(): void {
if (!this.pendingStatus || this.reason().invalid()) return;
this.facade.changeStatus(this.pendingStatus, this.reasonModel().text);
this.pendingStatus = null;
this.drawer.closeAll();
}
protected assign(assigneeId: string | null): void {
this.facade.assign(assigneeId || null);
}
protected setRelease(releaseId: string | null): void {
this.facade.setRelease(releaseId || null);
}
protected update(changes: {
priority?: IssuePriority;
area?: IssueArea | null;
}): void {
this.facade.update(changes);
}
protected link(): void {
const targetKey = this.linkTarget().trim();
if (!targetKey) return;
this.facade.link(
(this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType,
targetKey,
);
this.linkTarget.set('');
}
protected unlink(link: IssueLink): void {
this.facade.unlink(link);
}
protected addComment(): void {
const body = this.commentBody().trim();
if (!body) return;
this.facade.addComment(body);
this.commentBody.set('');
}
protected openEditComment(comment: Comment): void {
this.editingComment = comment.id;
this.commentEditModel.set({ body: comment.body });
this.drawer.open('Editar comentario', this.commentEditForm());
}
protected saveComment(): void {
if (!this.editingComment || this.commentEdit().invalid()) return;
this.facade.editComment(this.editingComment, this.commentEditModel().body);
this.drawer.closeAll();
}
protected deleteComment(comment: Comment): void {
if (confirm('¿Eliminar este comentario?'))
this.facade.deleteComment(comment.id);
}
protected openEdit(): void {
const issue = this.facade.issue();
if (!issue) return;
this.editModel.set({
title: issue.title,
description: issue.description ?? '',
labels: issue.labels.join(', '),
figmaUrl: issue.figmaUrl ?? '',
});
this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem');
}
protected saveEdit(): void {
if (this.edit().invalid()) return;
const v = this.editModel();
this.facade.update({
title: v.title,
description: v.description || null,
labels: v.labels
.split(',')
.map((l) => l.trim())
.filter(Boolean),
figmaUrl: v.figmaUrl || null,
});
this.drawer.closeAll();
}
}

View File

@@ -0,0 +1,273 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import {
PiActionButtonComponent,
PiIconButtonComponent,
PiLabelDirective,
PiTextFieldComponent,
PiSelectFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { AuthFacade } from '@task-stream/web/identity/application';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { ProjectMember, ProjectRole } from '@task-stream/web/tracker/domain';
import { initials, ROLE } from '../ui/copy';
const ROLE_TONE: Record<ProjectRole, string> = {
[ProjectRole.OWNER]: 'var(--ts-brand)',
[ProjectRole.ADMIN]: 'var(--ts-s-review)',
[ProjectRole.MEMBER]: 'var(--ts-s-todo)',
[ProjectRole.VIEWER]: 'var(--ts-s-cancelled)',
};
/** Team of the project. Users come from Pireon. */
@Component({
selector: 'ts-members-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiActionButtonComponent,
PiIconButtonComponent,
PiSelectFieldComponent,
PiTextFieldComponent,
PiLabelDirective,
],
template: `
<div class="ts-members">
<section class="ts-roster ts-panel" aria-labelledby="team-title">
<header class="ts-roster__head">
<h2 id="team-title">Equipo</h2>
<span class="ts-muted ts-mono"
>{{ facade.members().length }} personas</span
>
</header>
<ul class="ts-member-list">
@for (member of facade.members(); track member.user.id) {
<li>
<span class="ts-initials" aria-hidden="true">{{
initialsOf(member.user.name)
}}</span>
<span class="ts-member-list__name"
>{{ member.user.name }}
@if (member.user.id === me()) {
<span class="ts-you">tú</span>
}
</span>
@if (facade.canManage() && member.role !== 'OWNER') {
<pi-select-field
class="ts-role"
[options]="roleOptions"
[value]="member.role"
(valueChange)="changeRole(member, $event)"
/>
<pi-icon-button
icon="trash"
color="grey"
appearance="ghost"
aria-label="Quitar"
(execute)="remove(member)"
/>
} @else {
<span class="ts-pill" [style.--tone]="roleTone(member.role)">{{
roleLabel(member.role)
}}</span>
@if (member.user.id === me() && member.role !== 'OWNER') {
<pi-action-button
size="small"
appearance="outline"
color="grey"
text="Salir"
(execute)="remove(member)"
/>
}
}
</li>
}
</ul>
</section>
@if (facade.canManage()) {
<section class="ts-add ts-panel" aria-labelledby="add-title">
<h2 id="add-title">Añadir persona</h2>
<p class="ts-muted">Busca en el directorio de Pireon.</p>
<pi-text-field
placeholder="Nombre o email"
[value]="term()"
(valueChange)="search($event)"
>
<pi-label>Buscar</pi-label>
</pi-text-field>
<pi-select-field [options]="roleOptions" [(value)]="newRole">
<pi-label>Rol</pi-label>
</pi-select-field>
<ul class="ts-member-list">
@for (user of candidates(); track user.id) {
<li>
<span class="ts-initials" aria-hidden="true">{{
initialsOf(user.name)
}}</span>
<span class="ts-member-list__name"
>{{ user.name }}
<span class="ts-member-list__email">{{
user.email
}}</span></span
>
<pi-action-button
size="small"
color="primary"
text="Añadir"
(execute)="add(user.id)"
/>
</li>
}
</ul>
</section>
}
</div>
`,
styles: `
.ts-members {
display: grid;
grid-template-columns: minmax(0, 1fr) 24rem;
gap: 1.5rem;
align-items: start;
@media (max-width: 960px) {
grid-template-columns: minmax(0, 1fr);
}
}
.ts-roster,
.ts-add {
padding: 1.5rem;
}
.ts-roster__head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.5rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.ts-muted {
font-size: 0.75rem;
}
}
.ts-member-list {
display: grid;
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3rem;
margin: 0;
padding: 0.5rem 0;
border-bottom: 1px dashed var(--ts-line);
&:last-child {
border-bottom: 0;
}
}
}
.ts-initials {
flex: none;
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
font-family: var(--ts-font-mono);
font-size: 0.6875rem;
font-weight: 600;
}
.ts-member-list__name {
flex: 1;
min-width: 0;
font-weight: 600;
}
.ts-member-list__email {
display: block;
font-family: var(--ts-font-mono);
font-size: 0.75rem;
font-weight: 400;
color: var(--ts-muted);
}
.ts-you {
margin-left: 0.25rem;
font-family: var(--ts-font-mono);
font-size: 0.6875rem;
font-weight: 500;
color: var(--ts-muted);
}
.ts-role {
width: 10rem;
@media (max-width: 480px) {
width: 8rem;
}
}
.ts-add {
display: grid;
gap: 1.25rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
p {
margin: -0.875rem 0 0;
}
}
`,
})
export class MembersPage {
protected readonly facade = inject(ProjectFacade);
private readonly auth = inject(AuthFacade);
protected readonly me = computed(() => this.auth.user()?.id);
protected readonly term = signal('');
protected readonly newRole = signal<string | null>(ProjectRole.MEMBER);
protected readonly roleOptions: PiSelectFieldOption<string>[] = [
ProjectRole.ADMIN,
ProjectRole.MEMBER,
ProjectRole.VIEWER,
].map((value) => ({ value, label: ROLE[value] }));
protected readonly candidates = computed(() => {
const members = new Set(this.facade.members().map((m) => m.user.id));
return this.term()
? this.facade.userResults().filter((u) => !members.has(u.id))
: [];
});
protected roleLabel = (role: ProjectRole) => ROLE[role];
protected roleTone = (role: ProjectRole) => ROLE_TONE[role];
protected readonly initialsOf = initials;
protected search(term: string): void {
this.term.set(term);
this.facade.searchUsers(term);
}
protected add(userId: string): void {
this.facade.addMember(
userId,
(this.newRole() ?? ProjectRole.MEMBER) as ProjectRole,
);
}
protected changeRole(member: ProjectMember, role: string | null): void {
if (!role || role === member.role) return;
this.facade.changeMemberRole(member.user.id, role as ProjectRole);
}
protected remove(member: ProjectMember): void {
if (!confirm(`¿Quitar a ${member.user.name} del proyecto?`)) return;
this.facade.removeMember(member.user.id);
}
}

View File

@@ -0,0 +1,144 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
} from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { PiNoteComponent, PiSpinnerComponent } from '@pi/ui';
import {
ProjectFacade,
provideProjectFacade,
} from '@task-stream/web/tracker/application';
import { AvatarsComponent } from '../ui/avatars';
import { projectTone, ROLE } from '../ui/copy';
/** /p/:key — loads the project once for all its tabs (its store and facade are scoped here). */
@Component({
selector: 'ts-project-shell',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
AvatarsComponent,
PiSpinnerComponent,
PiNoteComponent,
],
providers: [provideProjectFacade()],
template: `
@if (facade.project(); as project) {
<header class="ts-project-header" [style.--tone]="tone(project.key)">
<div class="ts-project-header__main">
<p class="ts-eyebrow">
<a class="ts-plain-link" routerLink="/">Proyectos</a> /
{{ project.key }}
@if (facade.myRole(); as role) {
· {{ roleLabel(role) }}
}
</p>
<h1>
<span class="ts-project-header__key">{{ project.key }}</span>
{{ project.name }}
</h1>
@if (project.description) {
<p class="ts-project-header__description">
{{ project.description }}
</p>
}
</div>
<div class="ts-project-header__team">
<ts-avatars [names]="memberNames()" [max]="6" />
<a class="ts-project-header__people ts-mono" routerLink="members"
>{{ project.members.length }} en el equipo</a
>
</div>
</header>
<nav class="ts-tabs" aria-label="Secciones del proyecto">
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
<a routerLink="members" routerLinkActive="is-active">Miembros</a>
</nav>
<router-outlet />
} @else if (facade.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo abrir el proyecto"
[text]="error"
/>
} @else {
<div class="ts-center">
<pi-spinner [isLoading]="true" [diameter]="40" />
</div>
}
`,
styles: `
.ts-project-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
h1 {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.25rem 0.875rem;
margin: 0.5rem 0 0;
font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.75rem);
font-weight: 700;
line-height: 1.05;
}
}
.ts-project-header__main {
min-width: 0;
}
.ts-project-header__key {
font-family: var(--ts-font-mono);
font-size: 0.55em;
font-weight: 600;
letter-spacing: -0.03em;
color: var(--tone);
}
.ts-project-header__description {
max-width: 48rem;
margin: 0.5rem 0 0;
color: var(--ts-muted);
}
.ts-project-header__team {
display: flex;
align-items: center;
gap: 0.75rem;
}
.ts-project-header__people {
font-size: 0.75rem;
color: var(--ts-muted);
text-decoration: none;
&:hover {
color: var(--ts-ink);
}
}
`,
})
export class ProjectShellPage {
/** Route param (`withComponentInputBinding`). */
readonly key = input.required<string>();
protected readonly facade = inject(ProjectFacade);
protected readonly roleLabel = (role: keyof typeof ROLE) => ROLE[role];
protected readonly tone = projectTone;
protected readonly memberNames = computed(() =>
this.facade.members().map((m) => m.user.name),
);
constructor() {
effect(() => this.facade.open(this.key()));
}
}

View File

@@ -0,0 +1,258 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import {
form,
FormField,
maxLength,
pattern,
required,
} from '@angular/forms/signals';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import { AvatarsComponent } from '../ui/avatars';
import { projectTone, timeAgo } from '../ui/copy';
@Component({
selector: 'ts-projects-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
FormField,
AvatarsComponent,
PiActionButtonComponent,
PiEmptyStateComponent,
PiSkeletonComponent,
PiTextFieldComponent,
PiTextareaComponent,
PiLabelDirective,
],
template: `
<header class="ts-page-header">
<div>
<p class="ts-eyebrow">
Tus proyectos ·
<span class="ts-mono">{{ pad(facade.projects().length) }}</span>
</p>
<h1>Proyectos</h1>
<p>Los proyectos de los que formas parte, al día en tiempo real.</p>
</div>
<pi-action-button
color="primary"
text="Nuevo proyecto"
(execute)="openCreate()"
/>
</header>
@if (facade.loading() && !facade.projects().length) {
<div class="ts-projects">
@for (i of [1, 2, 3]; track i) {
<pi-skeleton shape="block" height="12rem" />
}
</div>
} @else if (!facade.projects().length) {
<pi-empty-state
icon="kanban"
title="Aún no tienes proyectos"
description="Crea uno o pide que te añadan a un proyecto existente."
>
<pi-action-button
color="primary"
text="Crear el primero"
(execute)="openCreate()"
/>
</pi-empty-state>
} @else {
<div class="ts-projects">
@for (project of facade.sorted(); track project.id) {
<a
class="ts-project ts-card-link"
[routerLink]="['/p', project.key]"
[style.--tone]="tone(project.key)"
>
<div class="ts-project__top">
<span class="ts-project__key">{{ project.key }}</span>
<span class="ts-project__updated ts-mono">{{
ago(project.updatedAt)
}}</span>
</div>
<h2 class="ts-project__name">{{ project.name }}</h2>
@if (project.description) {
<p class="ts-project__description">{{ project.description }}</p>
}
<div class="ts-project__bottom">
<ts-avatars [names]="memberNames(project.members)" />
<span class="ts-project__count ts-mono"
>{{ project.members.length }}
{{
project.members.length === 1 ? 'persona' : 'personas'
}}</span
>
<span class="ts-project__go" aria-hidden="true">→</span>
</div>
</a>
}
</div>
}
<ng-template #createForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); create()"
novalidate
>
<pi-text-field [formField]="f.key" placeholder="TS">
<pi-label [isRequired]="true"
>Clave (prefijo de los tickets)</pi-label
>
</pi-text-field>
<pi-text-field [formField]="f.name" placeholder="Checkout revamp">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<pi-textarea
[formField]="f.description"
placeholder="¿De qué va el proyecto?"
>
<pi-label>Descripción</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
[text]="facade.creating() ? 'Creando…' : 'Crear proyecto'"
[disabled]="f().invalid() || facade.creating()"
(execute)="create()"
/>
</form>
</ng-template>
`,
styles: `
.ts-projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
gap: 1.25rem;
}
.ts-project {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 0.375rem;
min-height: 12rem;
padding: 1.5rem 1.5rem 1.25rem;
&:hover .ts-project__go {
opacity: 1;
transform: none;
}
}
.ts-project__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.ts-project__key {
font-family: var(--ts-font-mono);
font-size: 1.75rem;
font-weight: 600;
letter-spacing: -0.04em;
color: var(--tone);
}
.ts-project__updated {
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-project__name {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.15;
}
.ts-project__description {
margin: 0;
color: var(--ts-muted);
font-size: 0.9rem;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ts-project__bottom {
display: flex;
align-items: center;
gap: 0.625rem;
margin-top: 0.75rem;
padding-top: 0.875rem;
border-top: 1px dashed var(--ts-line);
}
.ts-project__count {
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-project__go {
margin-left: auto;
font-size: 1.125rem;
color: var(--tone);
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
`,
})
export class ProjectsPage implements OnInit {
protected readonly facade = inject(ProjectsFacade);
private readonly drawer = inject(DrawerService);
private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly model = signal({ key: '', name: '', description: '' });
protected readonly f = form(this.model, (p) => {
required(p.key);
pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, {
message: '2-10 letras/números, empezando por letra',
});
required(p.name);
maxLength(p.name, 120);
});
protected readonly ago = timeAgo;
protected readonly tone = projectTone;
protected readonly pad = (n: number) => String(n).padStart(2, '0');
protected readonly memberNames = (members: { user: { name: string } }[]) =>
members.map((m) => m.user.name);
ngOnInit(): void {
this.facade.load();
}
protected openCreate(): void {
this.model.set({ key: '', name: '', description: '' });
this.drawer.open('Nuevo proyecto', this.createForm());
}
protected create(): void {
if (this.f().invalid()) return;
const { key, name, description } = this.model();
this.facade.create({
key: key.toUpperCase(),
name,
description: description || null,
});
this.drawer.closeAll();
}
}

View File

@@ -0,0 +1,345 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import { form, FormField, maxLength, required } from '@angular/forms/signals';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiTextareaComponent,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
import { RELEASE_STATUS, timeAgo } from '../ui/copy';
@Component({
selector: 'ts-releases-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
FormField,
PiActionButtonComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiTextFieldComponent,
PiTextareaComponent,
],
template: `
<div class="ts-section-header">
<p class="ts-muted">
Controla qué tickets entran en cada versión. Una release no se publica
con tickets sin terminar.
</p>
@if (facade.canManage()) {
<pi-action-button
color="primary"
text="Nueva release"
(execute)="openCreate()"
/>
}
</div>
<ol class="ts-releases">
@for (release of facade.releases(); track release.id) {
<li
class="ts-release"
[attr.data-status]="release.status"
[style.--tone]="toneOf(release)"
>
<span class="ts-release__node" aria-hidden="true"></span>
<div class="ts-release__body ts-panel">
<header class="ts-release__head">
<h2 class="ts-release__name">{{ release.name }}</h2>
<span class="ts-pill" [style.--tone]="toneOf(release)">{{
statusOf(release).label
}}</span>
<span class="ts-release__when ts-mono">{{ when(release) }}</span>
</header>
@if (release.description) {
<p class="ts-release__description">{{ release.description }}</p>
}
<div class="ts-release__progress">
<div
class="ts-meter"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="release.progress.percent"
[attr.aria-label]="'Progreso de ' + release.name"
>
<span [style.width.%]="release.progress.percent"></span>
</div>
<span class="ts-mono"
><strong>{{ release.progress.finished }}</strong
>/{{ release.progress.total }} tickets ·
{{ release.progress.percent }}%</span
>
</div>
<div class="ts-release__actions">
<a
class="ts-release__link"
[routerLink]="['../board']"
[queryParams]="{ release: release.id }"
>Ver en el tablero →</a
>
<span class="ts-spacer"></span>
@if (facade.canManage() && release.status === 'PLANNED') {
<pi-action-button
size="small"
appearance="outline"
color="grey"
text="Archivar"
(execute)="archive(release)"
/>
<pi-action-button
size="small"
color="primary"
text="Publicar"
(execute)="publish(release)"
/>
}
</div>
</div>
</li>
} @empty {
<pi-empty-state
icon="rocket"
title="Sin releases"
description="Crea una versión y mete en ella los tickets que deben salir juntos."
/>
}
</ol>
<ng-template #createForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); create()"
novalidate
>
<pi-text-field [formField]="f.name" placeholder="v2.0.0">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<pi-textarea [formField]="f.description" placeholder="Qué incluye">
<pi-label>Descripción</pi-label>
</pi-textarea>
<pi-text-field [formField]="f.targetDate" placeholder="2026-12-01">
<pi-label>Fecha objetivo (AAAA-MM-DD)</pi-label>
</pi-text-field>
<pi-action-button
size="w100"
color="primary"
text="Crear release"
[disabled]="f().invalid()"
(execute)="create()"
/>
</form>
</ng-template>
`,
styles: `
/* A timeline: one node per version on a vertical rail. */
.ts-releases {
position: relative;
display: grid;
gap: 1rem;
margin: 0;
padding: 0 0 0 2rem;
list-style: none;
&::before {
content: '';
position: absolute;
inset: 0.75rem auto 0.75rem 0.5625rem;
width: 2px;
background: var(--ts-line-strong);
}
}
.ts-release {
position: relative;
margin: 0;
}
.ts-release__node {
position: absolute;
left: -2rem;
top: 1.375rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--ts-bg);
border: 3px solid var(--tone);
box-sizing: border-box;
}
.ts-release[data-status='RELEASED'] .ts-release__node {
background: var(--tone);
}
.ts-release__body {
padding: 1.25rem 1.5rem 1.125rem;
}
.ts-release[data-status='ARCHIVED'] .ts-release__body {
opacity: 0.65;
}
.ts-release__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.875rem;
}
.ts-release__name {
margin: 0;
font-family: var(--ts-font-mono);
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.04em;
}
.ts-release__when {
margin-left: auto;
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-release__description {
margin: 0.375rem 0 0;
color: var(--ts-muted);
}
.ts-release__progress {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 1rem;
margin-top: 1rem;
font-size: 0.8125rem;
color: var(--ts-muted);
strong {
color: var(--ts-ink);
}
}
.ts-meter {
height: 0.5rem;
border-radius: 0.25rem;
background: var(--ts-sunken);
overflow: hidden;
span {
display: block;
height: 100%;
border-radius: 0.25rem;
background: var(--ts-s-done);
}
}
.ts-release__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.875rem;
}
.ts-release__link {
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
}
.ts-spacer {
flex: 1;
}
.ts-release__link {
white-space: nowrap;
}
@media (max-width: 640px) {
.ts-releases {
padding-left: 1.625rem;
}
.ts-release__node {
left: -1.625rem;
}
.ts-release__body {
padding: 1rem;
}
.ts-release__when {
flex-basis: 100%;
margin-left: 0;
}
.ts-release__progress {
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem;
}
.ts-release__actions {
flex-wrap: wrap;
}
}
`,
})
export class ReleasesPage {
protected readonly facade = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly model = signal({
name: '',
description: '',
targetDate: '',
});
protected readonly f = form(this.model, (p) => {
required(p.name);
maxLength(p.name, 60);
});
protected statusOf = (release: Release) =>
RELEASE_STATUS[release.status as ReleaseStatus];
protected toneOf = (release: Release) =>
release.status === ReleaseStatus.RELEASED
? 'var(--ts-s-done)'
: release.status === ReleaseStatus.ARCHIVED
? 'var(--ts-s-cancelled)'
: 'var(--ts-s-progress)';
/** "publicada hace 2 días", "objetivo 17 oct · en 14 días". */
protected when = (release: Release) => {
if (release.releasedAt) return `publicada ${timeAgo(release.releasedAt)}`;
if (!release.targetDate) return 'sin fecha';
const target = new Date(release.targetDate);
const days = Math.ceil((target.getTime() - Date.now()) / 86_400_000);
const date = target.toLocaleDateString('es', {
day: 'numeric',
month: 'short',
});
if (days < 0) return `objetivo ${date} · retrasada ${-days} d`;
return days === 0 ? `objetivo hoy` : `objetivo ${date} · en ${days} d`;
};
protected openCreate(): void {
this.model.set({ name: '', description: '', targetDate: '' });
this.drawer.open('Nueva release', this.createForm());
}
protected create(): void {
if (this.f().invalid()) return;
const v = this.model();
this.facade.createRelease({
name: v.name,
description: v.description || null,
targetDate: v.targetDate || null,
});
this.drawer.closeAll();
}
protected publish(release: Release): void {
const unfinished = release.progress.total - release.progress.finished;
if (unfinished > 0) {
if (
!confirm(
`${release.name} tiene ${unfinished} ticket(s) sin terminar. ¿Publicar igualmente?`,
)
)
return;
this.facade.publishRelease(release.id, true);
return;
}
this.facade.publishRelease(release.id);
}
protected archive(release: Release): void {
this.facade.archiveRelease(release.id);
}
}

View File

@@ -0,0 +1,55 @@
import { Routes } from '@angular/router';
/** Everything behind authentication. */
export const trackerRoutes: Routes = [
{
path: '',
title: 'Proyectos · TaskStream',
loadComponent: () =>
import('./pages/projects.page').then((m) => m.ProjectsPage),
},
{
path: 'p/:key',
loadComponent: () =>
import('./pages/project-shell.page').then((m) => m.ProjectShellPage),
children: [
{ path: '', pathMatch: 'full', redirectTo: 'board' },
{
path: 'board',
title: 'Tablero · TaskStream',
loadComponent: () =>
import('./pages/board.page').then((m) => m.BoardPage),
},
{
path: 'issues/:issueKey',
title: 'Ticket · TaskStream',
loadComponent: () =>
import('./pages/issue.page').then((m) => m.IssuePage),
},
{
path: 'releases',
title: 'Releases · TaskStream',
loadComponent: () =>
import('./pages/releases.page').then((m) => m.ReleasesPage),
},
{
path: 'dashboard',
title: 'Seguimiento · TaskStream',
loadComponent: () =>
import('./pages/dashboard.page').then((m) => m.DashboardPage),
},
{
path: 'activity',
title: 'Actividad · TaskStream',
loadComponent: () =>
import('./pages/activity.page').then((m) => m.ActivityPage),
},
{
path: 'members',
title: 'Miembros · TaskStream',
loadComponent: () =>
import('./pages/members.page').then((m) => m.MembersPage),
},
],
},
];

View File

@@ -0,0 +1,49 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { initials } from './copy';
/** Overlapping initials (members of a project, people on a ticket). */
@Component({
selector: 'ts-avatars',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (name of names().slice(0, max()); track $index) {
<span class="ts-avatar" [attr.title]="name">{{ initialsOf(name) }}</span>
}
@if (names().length > max()) {
<span class="ts-avatar ts-avatar--more"
>+{{ names().length - max() }}</span
>
}
`,
styles: `
:host {
display: inline-flex;
align-items: center;
padding-left: 0.375rem;
}
.ts-avatar {
display: inline-grid;
place-items: center;
width: var(--ts-avatar-size, 1.75rem);
height: var(--ts-avatar-size, 1.75rem);
margin-left: -0.375rem;
border-radius: 50%;
border: 2px solid var(--ts-surface);
background: color-mix(in srgb, var(--ts-brand) 18%, var(--ts-surface));
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
font-family: var(--ts-font-mono);
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0;
}
.ts-avatar--more {
background: var(--ts-sunken);
color: var(--ts-muted);
}
`,
})
export class AvatarsComponent {
readonly names = input.required<readonly string[]>();
readonly max = input(5);
protected readonly initialsOf = initials;
}

View File

@@ -0,0 +1,243 @@
import { DatasetColor } from '@pi/ui';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
type ChipColor =
| 'accent'
| 'grey'
| 'highlighted'
| 'primary'
| 'success'
| 'warn';
type IconColor =
| 'primary'
| 'accent'
| 'warn'
| 'success'
| 'grey'
| 'highlighted';
/* UI copy (es) and the visual language of the tracker. */
/** `tone`: CSS colour token of the status/type (see `--ts-s-*`, `--ts-t-*` in styles.scss). */
export const STATUS: Record<
IssueStatus,
{ label: string; color: ChipColor; chart: DatasetColor; tone: string }
> = {
[IssueStatus.BACKLOG]: {
label: 'Backlog',
color: 'grey',
chart: DatasetColor.grey,
tone: 'var(--ts-s-backlog)',
},
[IssueStatus.TODO]: {
label: 'Por hacer',
color: 'grey',
chart: DatasetColor.grey300,
tone: 'var(--ts-s-todo)',
},
[IssueStatus.IN_PROGRESS]: {
label: 'En curso',
color: 'primary',
chart: DatasetColor.primary,
tone: 'var(--ts-s-progress)',
},
[IssueStatus.IN_REVIEW]: {
label: 'En revisión',
color: 'accent',
chart: DatasetColor.accent,
tone: 'var(--ts-s-review)',
},
[IssueStatus.READY_FOR_QA]: {
label: 'Listo para QA',
color: 'highlighted',
chart: DatasetColor.highlighted,
tone: 'var(--ts-s-ready)',
},
[IssueStatus.IN_QA]: {
label: 'En QA',
color: 'highlighted',
chart: DatasetColor.highlighted50,
tone: 'var(--ts-s-qa)',
},
[IssueStatus.QA_FAILED]: {
label: 'QA fallido',
color: 'warn',
chart: DatasetColor.warn,
tone: 'var(--ts-s-failed)',
},
[IssueStatus.DONE]: {
label: 'Hecho',
color: 'success',
chart: DatasetColor.success,
tone: 'var(--ts-s-done)',
},
[IssueStatus.CANCELLED]: {
label: 'Cancelado',
color: 'grey',
chart: DatasetColor.grey50,
tone: 'var(--ts-s-cancelled)',
},
};
export const TYPE: Record<
IssueType,
{ label: string; icon: string; color: IconColor; tone: string }
> = {
[IssueType.BUG]: {
label: 'Bug',
icon: 'bug',
color: 'warn',
tone: 'var(--ts-t-bug)',
},
[IssueType.FEATURE]: {
label: 'Feature',
icon: 'feature',
color: 'primary',
tone: 'var(--ts-t-feature)',
},
[IssueType.UI_CHANGE]: {
label: 'Cambio de UI',
icon: 'palette',
color: 'accent',
tone: 'var(--ts-t-ui)',
},
[IssueType.TASK]: {
label: 'Tarea',
icon: 'task',
color: 'grey',
tone: 'var(--ts-t-task)',
},
[IssueType.QA]: {
label: 'QA',
icon: 'qa',
color: 'highlighted',
tone: 'var(--ts-t-qa)',
},
};
/** Signal strength of a priority, drawn as bars (1–4) like a phone signal. */
export const PRIORITY_LEVEL: Record<IssuePriority, number> = {
[IssuePriority.CRITICAL]: 4,
[IssuePriority.HIGH]: 3,
[IssuePriority.MEDIUM]: 2,
[IssuePriority.LOW]: 1,
[IssuePriority.LOWEST]: 0,
};
export const PRIORITY: Record<
IssuePriority,
{ label: string; color: ChipColor }
> = {
[IssuePriority.CRITICAL]: { label: 'Crítica', color: 'warn' },
[IssuePriority.HIGH]: { label: 'Alta', color: 'accent' },
[IssuePriority.MEDIUM]: { label: 'Media', color: 'primary' },
[IssuePriority.LOW]: { label: 'Baja', color: 'grey' },
[IssuePriority.LOWEST]: { label: 'Mínima', color: 'grey' },
};
export const AREA: Record<IssueArea, string> = {
[IssueArea.FRONTEND]: 'Frontend',
[IssueArea.BACKEND]: 'Backend',
[IssueArea.MOBILE]: 'Mobile',
[IssueArea.QA]: 'QA',
[IssueArea.DESIGN]: 'Diseño',
[IssueArea.DEVOPS]: 'DevOps',
};
export const LINK: Record<IssueLinkType, { outward: string; inward: string }> =
{
[IssueLinkType.BLOCKS]: {
outward: 'bloquea a',
inward: 'está bloqueado por',
},
[IssueLinkType.DEPENDS_ON]: {
outward: 'depende de',
inward: 'es dependencia de',
},
[IssueLinkType.TESTS]: { outward: 'prueba', inward: 'es probado por' },
[IssueLinkType.DUPLICATES]: {
outward: 'duplica a',
inward: 'está duplicado por',
},
[IssueLinkType.RELATES_TO]: {
outward: 'relacionado con',
inward: 'relacionado con',
},
};
export const ROLE: Record<ProjectRole, string> = {
[ProjectRole.OWNER]: 'Propietario',
[ProjectRole.ADMIN]: 'Admin',
[ProjectRole.MEMBER]: 'Miembro',
[ProjectRole.VIEWER]: 'Lector',
};
export const RELEASE_STATUS: Record<
ReleaseStatus,
{ label: string; color: ChipColor }
> = {
[ReleaseStatus.PLANNED]: { label: 'Planificada', color: 'primary' },
[ReleaseStatus.RELEASED]: { label: 'Publicada', color: 'success' },
[ReleaseStatus.ARCHIVED]: { label: 'Archivada', color: 'grey' },
};
export const options = <T extends string>(
record: Record<T, string | { label: string }>,
) =>
(Object.keys(record) as T[]).map((value) => {
const entry = record[value];
return { value, label: typeof entry === 'string' ? entry : entry.label };
});
/** "hace 5 min", "ayer"… */
export function timeAgo(iso: string, now = Date.now()): string {
const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
const rtf = new Intl.RelativeTimeFormat('es', { numeric: 'auto' });
if (seconds < 60) return rtf.format(-seconds, 'second');
if (seconds < 3600) return rtf.format(-Math.round(seconds / 60), 'minute');
if (seconds < 86400) return rtf.format(-Math.round(seconds / 3600), 'hour');
return rtf.format(-Math.round(seconds / 86400), 'day');
}
const PROJECT_TONES = [
'var(--ts-t-feature)',
'var(--ts-s-review)',
'var(--ts-t-ui)',
'var(--ts-t-qa)',
'var(--ts-s-done)',
'var(--ts-s-qa)',
];
/** A stable colour per project (from its key), so it is recognisable everywhere. */
export function projectTone(key: string): string {
let hash = 0;
for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
return PROJECT_TONES[hash % PROJECT_TONES.length];
}
/** "Ana García" → "AG". */
export function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
/** "14:05" today, "3 oct" otherwise (logs and histories). */
export function clock(iso: string, now = new Date()): string {
const date = new Date(iso);
return date.toDateString() === now.toDateString()
? date.toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
: date.toLocaleDateString('es', { day: 'numeric', month: 'short' });
}

View File

@@ -0,0 +1,216 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '@pi/ui';
import { IssueSummary } from '@task-stream/web/tracker/domain';
import { AREA, initials, PRIORITY, PRIORITY_LEVEL, TYPE } from './copy';
/** A ticket on the board / in lists: type stripe, key, title, signals. */
@Component({
selector: 'ts-issue-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent],
template: `
<a
class="ts-card"
[routerLink]="['..', 'issues', issue().key]"
[style.--type]="type().tone"
[class.ts-card--critical]="issue().priority === 'CRITICAL'"
>
<div class="ts-card__top">
<span class="ts-card__type" [attr.title]="type().label">
<pi-icon [icon]="type().icon" size="small" />
</span>
<span class="ts-card__key">{{ issue().key }}</span>
@if (issue().area; as area) {
<span class="ts-card__area">{{ areaLabel(area) }}</span>
}
</div>
<p class="ts-card__title">{{ issue().title }}</p>
<div class="ts-card__bottom">
<span
class="ts-signal"
[attr.data-level]="level()"
[attr.title]="'Prioridad ' + priority().label.toLowerCase()"
[attr.aria-label]="'Prioridad ' + priority().label.toLowerCase()"
>
<i></i><i></i><i></i><i></i>
</span>
@if (issue().release; as release) {
<span class="ts-card__meta" title="Release"
>◆ {{ release.name }}</span
>
}
@if (issue().commentCount) {
<span class="ts-card__meta" title="Comentarios">
<pi-icon icon="chat" size="x-small" />
{{ issue().commentCount }}
</span>
}
<span class="ts-card__spacer"></span>
@if (issue().assignee; as assignee) {
<span class="ts-card__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
} @else {
<span class="ts-card__who ts-card__who--none" title="Sin asignar"
>—</span
>
}
</div>
</a>
`,
styles: `
:host {
display: block;
}
.ts-card {
--type: var(--ts-muted);
position: relative;
display: grid;
gap: 0.5rem;
padding: 0.75rem 0.75rem 0.6875rem 0.9375rem;
overflow: hidden;
border-radius: 0.625rem;
background: var(--ts-surface);
border: 1px solid var(--ts-line);
color: inherit;
text-decoration: none;
box-shadow: 0 1px 0 rgb(0 0 0 / 3%);
transition:
box-shadow 0.15s ease,
transform 0.15s ease,
border-color 0.15s ease;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--type);
}
&:hover {
border-color: color-mix(in srgb, var(--type) 40%, var(--ts-line));
box-shadow: 0 10px 22px -16px
color-mix(in srgb, var(--type) 70%, transparent);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--type);
outline-offset: 2px;
}
}
.ts-card__top,
.ts-card__bottom {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.ts-card__type {
display: inline-grid;
place-items: center;
width: 1.375rem;
height: 1.375rem;
border-radius: 0.375rem;
color: var(--type);
background: color-mix(in srgb, var(--type) 13%, transparent);
--pi-icon-size: 0.875rem;
}
.ts-card__key {
font-family: var(--ts-font-mono);
font-size: 0.75rem;
font-weight: 600;
color: var(--ts-ink-soft);
}
.ts-card__area {
margin-left: auto;
font-family: var(--ts-font-mono);
font-size: 0.625rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ts-muted);
}
.ts-card__title {
margin: 0;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.35;
color: var(--ts-ink);
}
.ts-card__meta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: var(--ts-font-mono);
font-size: 0.6875rem;
color: var(--ts-muted);
white-space: nowrap;
}
.ts-card__spacer {
flex: 1;
}
.ts-card__who {
display: inline-grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
font-family: var(--ts-font-mono);
font-size: 0.5625rem;
font-weight: 600;
}
.ts-card__who--none {
background: transparent;
border: 1px dashed var(--ts-line-strong);
color: var(--ts-muted);
}
/* Priority as signal bars: as many lit bars as the level. */
.ts-signal {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 0.75rem;
i {
width: 3px;
border-radius: 1px;
background: var(--ts-line-strong);
}
i:nth-child(1) {
height: 35%;
}
i:nth-child(2) {
height: 55%;
}
i:nth-child(3) {
height: 78%;
}
i:nth-child(4) {
height: 100%;
}
&[data-level='1'] i:nth-child(-n + 1),
&[data-level='2'] i:nth-child(-n + 2),
&[data-level='3'] i:nth-child(-n + 3) {
background: var(--ts-ink-soft);
}
&[data-level='4'] i {
background: var(--ts-s-failed);
}
}
`,
})
export class IssueCardComponent {
readonly issue = input.required<IssueSummary>();
protected readonly type = computed(() => TYPE[this.issue().type]);
protected readonly priority = computed(() => PRIORITY[this.issue().priority]);
protected readonly level = computed(
() => PRIORITY_LEVEL[this.issue().priority],
);
protected readonly areaLabel = (area: keyof typeof AREA) => AREA[area];
protected readonly initialsOf = initials;
}

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}