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()) {
} @else {

Mis tickets

{{ home.mineTotal() }} abiertos
@if (home.qaFailed()) {

} @for (group of visibleGroups(); track group.category) {

{{ categoryLabel(group.category) }} {{ group.issues.length }}

} @empty {

No tienes tickets abiertos asignados. Buen momento para revisar lo que espera QA.

} @if (home.mineTotal() > shown()) {

y {{ home.mineTotal() - shown() }} más, en los tableros de cada proyecto

}

Esperando QA

{{ home.awaitingQaTotal() }}

Próximas releases

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