import { ChangeDetectionStrategy, Component, computed, input, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { PiIconComponent } from '@pi/ui'; import { DueState, dueState, IssueSummary, Workflow, } from '@task-stream/web/tracker/domain'; import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } 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: `
{{ issue().key }} @if (issue().area; as area) { {{ areaLabel(area) }} }

{{ issue().title }}

@if (issue().parent; as parent) { ↑ {{ parent.key }} } @if (due(); as state) { {{ dueLabel(state) }} } @if (issue().storyPoints !== null) { {{ issue().storyPoints }} } @if (issue().release; as release) { ◆ {{ release.name }} } @if (issue().commentCount) { {{ issue().commentCount }} } @if (issue().assignee; as assignee) { {{ initialsOf(assignee.name) }} } @else { — }
`, styles: ` :host { display: block; } .ts-card__due { padding: 0 0.375rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; background: color-mix( in srgb, var(--ts-c-amber, #d97706) 18%, transparent ); &[data-due='overdue'] { background: color-mix( in srgb, var(--ts-c-red, #dc2626) 18%, transparent ); color: var(--ts-c-red, inherit); } } .ts-card__points { display: inline-grid; place-items: center; min-width: 1.25rem; height: 1.25rem; padding: 0 0.3rem; border-radius: 999px; background: var(--ts-line); color: var(--ts-ink-soft); font-size: 0.6875rem; font-weight: 600; } .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 { /** Late, due today or in the next two days. */ protected readonly due = computed(() => dueState(this.issue().dueDate, this.issue().statusCategory), ); protected dueLabel(state: DueState): string { if (state === 'overdue') return 'Vencido'; if (state === 'today') return 'Vence hoy'; const day = new Date(`${this.issue().dueDate?.slice(0, 10)}T00:00:00`); return day.toLocaleDateString('es', { day: 'numeric', month: 'short' }); } readonly issue = input.required(); /** The project's settings: names, icons and colours of types and areas. */ readonly workflow = input.required(); protected readonly type = computed(() => typeLook(this.workflow(), this.issue().type), ); protected readonly priority = computed(() => PRIORITY[this.issue().priority]); protected readonly level = computed( () => PRIORITY_LEVEL[this.issue().priority], ); protected readonly areaLabel = (area: string) => this.workflow().area(area).name; protected readonly initialsOf = initials; }