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