feat: per-project workflow settings, statistics and "my work" home
Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
"requires reason"; configurable transitions; ticket types (icon, colour) and
areas. Defaults are the previous fixed values; projects created before keep
them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
literals); Project.settings, updateProjectSettings, statusCategory,
myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).
Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
pass/fail and release burnup. GraphQL Project.analytics(days),
Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
keyboard navigation, legend and data table.
Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".
Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
452
libs/web/tracker/presenters/src/lib/pages/my-work.ts
Normal file
452
libs/web/tracker/presenters/src/lib/pages/my-work.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
DOCUMENT,
|
||||
inject,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
|
||||
import {
|
||||
HomeFacade,
|
||||
ProjectsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
IssueSummary,
|
||||
Release,
|
||||
StatusCategory,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
CATEGORY,
|
||||
initials,
|
||||
projectKeyOf,
|
||||
projectTone,
|
||||
releaseWhen,
|
||||
statusLook,
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
|
||||
const VISIBLE = 12;
|
||||
|
||||
/**
|
||||
* "Mi trabajo" on the home page, across every project I belong to: my open
|
||||
* tickets (QA failures first), tickets waiting for QA and upcoming releases.
|
||||
* Reloads when the tab becomes visible again.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-my-work',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiIconComponent, PiSkeletonComponent],
|
||||
template: `
|
||||
@if (!home.loaded()) {
|
||||
<div class="ts-work">
|
||||
<pi-skeleton shape="block" height="18rem" />
|
||||
<div class="ts-work__side">
|
||||
<pi-skeleton shape="block" height="8rem" />
|
||||
<pi-skeleton shape="block" height="8rem" />
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ts-work">
|
||||
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
|
||||
<header class="ts-work__head">
|
||||
<h2 id="mine-title">Mis tickets</h2>
|
||||
<span class="ts-muted ts-mono"
|
||||
>{{ home.mineTotal() }} abiertos</span
|
||||
>
|
||||
</header>
|
||||
@if (home.qaFailed()) {
|
||||
<p class="ts-work__alert" role="status">
|
||||
<pi-icon icon="exclamation" size="small" aria-hidden="true" />
|
||||
{{ home.qaFailed() }}
|
||||
{{
|
||||
home.qaFailed() === 1
|
||||
? 'ticket tuyo ha fallado en QA'
|
||||
: 'tickets tuyos han fallado en QA'
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
@for (group of visibleGroups(); track group.category) {
|
||||
<h3 class="ts-work__group">
|
||||
<span
|
||||
class="ts-pill"
|
||||
[style.--tone]="categoryTone(group.category)"
|
||||
>{{ categoryLabel(group.category) }}</span
|
||||
>
|
||||
<span class="ts-mono">{{ group.issues.length }}</span>
|
||||
</h3>
|
||||
<ul class="ts-work__list">
|
||||
@for (issue of group.issues; track issue.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-work__row"
|
||||
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
|
||||
[style.--type]="typeTone(issue)"
|
||||
>
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-work__title"
|
||||
><span
|
||||
class="ts-work__status"
|
||||
[style.--tone]="statusTone(issue)"
|
||||
>{{ statusLabel(issue) }}</span
|
||||
>
|
||||
{{ issue.title }}</span
|
||||
>
|
||||
<span
|
||||
class="ts-work__project"
|
||||
[style.--tone]="projectToneOf(issue.key)"
|
||||
>{{ keyOf(issue.key) }}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @empty {
|
||||
<p class="ts-work__empty">
|
||||
No tienes tickets abiertos asignados. Buen momento para revisar lo
|
||||
que espera QA.
|
||||
</p>
|
||||
}
|
||||
@if (home.mineTotal() > shown()) {
|
||||
<p class="ts-muted ts-work__more">
|
||||
y {{ home.mineTotal() - shown() }} más, en los tableros de cada
|
||||
proyecto
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="ts-work__side">
|
||||
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
|
||||
<header class="ts-work__head">
|
||||
<h2 id="qa-title">Esperando QA</h2>
|
||||
<span class="ts-muted ts-mono">{{ home.awaitingQaTotal() }}</span>
|
||||
</header>
|
||||
<ul class="ts-work__list">
|
||||
@for (issue of home.awaitingQa().slice(0, 6); track issue.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-work__row ts-work__row--qa"
|
||||
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
|
||||
[style.--type]="statusTone(issue)"
|
||||
>
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-work__title">{{ issue.title }}</span>
|
||||
@if (issue.assignee; as assignee) {
|
||||
<span class="ts-work__who" [attr.title]="assignee.name">{{
|
||||
initialsOf(assignee.name)
|
||||
}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="ts-work__empty">Nada esperando QA.</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="ts-panel ts-work__panel"
|
||||
aria-labelledby="releases-title"
|
||||
>
|
||||
<header class="ts-work__head">
|
||||
<h2 id="releases-title">Próximas releases</h2>
|
||||
</header>
|
||||
<ul class="ts-work__list">
|
||||
@for (release of home.releases(); track release.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-work__release"
|
||||
[routerLink]="['/p', projectOf(release), 'releases']"
|
||||
>
|
||||
<span class="ts-work__version">{{ release.name }}</span>
|
||||
<span
|
||||
class="ts-work__project"
|
||||
[style.--tone]="tone(projectOf(release))"
|
||||
>{{ projectOf(release) }}</span
|
||||
>
|
||||
<span class="ts-work__when ts-mono">{{
|
||||
when(release)
|
||||
}}</span>
|
||||
<span
|
||||
class="ts-work__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
|
||||
></span>
|
||||
<span class="ts-work__percent ts-mono"
|
||||
>{{ release.progress.finished }}/{{
|
||||
release.progress.total
|
||||
}}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="ts-work__empty">Ninguna release planificada.</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.ts-work {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
@media (max-width: 960px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.ts-work__side {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.ts-work__panel {
|
||||
padding: 1.375rem 1.5rem 1.25rem;
|
||||
}
|
||||
.ts-work__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.875rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.ts-work__alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: color-mix(in srgb, var(--ts-s-failed) 10%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-s-failed) 85%, var(--ts-ink));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-work__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.875rem 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.ts-work__list {
|
||||
display: grid;
|
||||
margin: 0 -0.5rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.ts-work__row {
|
||||
--type: var(--ts-muted);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
|
||||
border-radius: 0.625rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
box-shadow: inset 3px 0 0 var(--type);
|
||||
transition: background-color 0.15s ease;
|
||||
&:hover {
|
||||
background: var(--pi-color-primary-25);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
.ts-work__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-work__status {
|
||||
margin-right: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--tone);
|
||||
}
|
||||
.ts-work__project {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--tone);
|
||||
}
|
||||
.ts-work__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-work__release {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
'version project when'
|
||||
'meter meter percent';
|
||||
align-items: center;
|
||||
gap: 0.375rem 0.625rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.625rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background: var(--pi-color-primary-25);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
.ts-work__version {
|
||||
grid-area: version;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.ts-work__release .ts-work__project {
|
||||
grid-area: project;
|
||||
}
|
||||
.ts-work__when {
|
||||
grid-area: when;
|
||||
justify-self: end;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-work__meter {
|
||||
grid-area: meter;
|
||||
height: 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ts-s-done);
|
||||
}
|
||||
}
|
||||
.ts-work__percent {
|
||||
grid-area: percent;
|
||||
justify-self: end;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-work__empty {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-work__more {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
protected readonly home = inject(HomeFacade);
|
||||
private readonly projects = inject(ProjectsFacade);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
/** Groups trimmed to the first VISIBLE tickets overall. */
|
||||
protected readonly visibleGroups = computed(() => {
|
||||
let left = VISIBLE;
|
||||
return this.home
|
||||
.mineByCategory()
|
||||
.map((group) => {
|
||||
const issues = group.issues.slice(0, Math.max(left, 0));
|
||||
left -= issues.length;
|
||||
return { ...group, issues };
|
||||
})
|
||||
.filter((group) => group.issues.length > 0);
|
||||
});
|
||||
protected readonly shown = computed(() =>
|
||||
this.visibleGroups().reduce((sum, group) => sum + group.issues.length, 0),
|
||||
);
|
||||
|
||||
protected readonly keyOf = projectKeyOf;
|
||||
protected readonly tone = projectTone;
|
||||
protected readonly initialsOf = initials;
|
||||
protected readonly when = releaseWhen;
|
||||
/** Each ticket is shown with its own project's names and colours. */
|
||||
private readonly workflows = computed(
|
||||
() =>
|
||||
new Map(
|
||||
this.projects
|
||||
.projects()
|
||||
.map((p) => [p.id, new Workflow(p.settings)] as const),
|
||||
),
|
||||
);
|
||||
private workflowOf(issue: IssueSummary): Workflow {
|
||||
return this.workflows().get(issue.projectId) ?? new Workflow();
|
||||
}
|
||||
protected readonly statusLabel = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).label;
|
||||
protected readonly statusTone = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).tone;
|
||||
protected readonly typeTone = (issue: IssueSummary) =>
|
||||
typeLook(this.workflowOf(issue), issue.type).tone;
|
||||
protected readonly categoryLabel = (category: StatusCategory) =>
|
||||
CATEGORY[category].label;
|
||||
protected readonly categoryTone = (category: StatusCategory) =>
|
||||
CATEGORY[category].tone;
|
||||
protected readonly projectToneOf = (issueKey: string) =>
|
||||
projectTone(projectKeyOf(issueKey));
|
||||
|
||||
ngOnInit(): void {
|
||||
this.home.load();
|
||||
// Back to the tab after a while: refresh my work.
|
||||
const onVisible = () => {
|
||||
if (this.document.visibilityState === 'visible') this.home.load();
|
||||
};
|
||||
this.document.addEventListener('visibilitychange', onVisible);
|
||||
this.destroyRef.onDestroy(() =>
|
||||
this.document.removeEventListener('visibilitychange', onVisible),
|
||||
);
|
||||
}
|
||||
|
||||
/** Releases carry the project id: shown and linked by the project's key. */
|
||||
protected projectOf(release: Release): string {
|
||||
return (
|
||||
this.projects.projects().find((p) => p.id === release.projectId)?.key ??
|
||||
release.projectId
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user