Owners and admins turn it on in Configuración → General, pick what it
shows (tickets per status, the sprint under way, release forecasts,
open epics and activity per day) and can renew or drop the link. Whoever
has it sees /share/{token} without an account: only those blocks, no
people nor single tickets but the epics, no links into the app, read
again every minute. A renewed or dropped link says it no longer works.
REST (GET/PUT/DELETE /api/projects/{project}/share and the public
GET /api/shared/dashboards/{token}) and GraphQL (dashboardShare,
shareDashboard, unshareDashboard, sharedDashboard). The token is kept
in dashboard_shares and goes with the project.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
8.9 KiB
TypeScript
322 lines
8.9 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
input,
|
|
untracked,
|
|
} from '@angular/core';
|
|
import { PiEmptyStateComponent, PiSkeletonComponent } from '@pi/ui';
|
|
import {
|
|
provideSharedDashboardFacade,
|
|
SharedDashboardFacade,
|
|
} from '@task-stream/web/tracker/application';
|
|
import { tone } from '../ui/copy';
|
|
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
|
|
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
|
|
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
|
|
|
/**
|
|
* /share/:token — a project's dashboard for its clients, without an
|
|
* account: only the blocks its owners chose, read again every minute.
|
|
*/
|
|
@Component({
|
|
selector: 'ts-shared-dashboard-page',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
PiEmptyStateComponent,
|
|
PiSkeletonComponent,
|
|
SprintPulseComponent,
|
|
ReleaseForecastComponent,
|
|
ActivityHeatmapComponent,
|
|
],
|
|
template: `
|
|
@if (shared.missing()) {
|
|
<pi-empty-state
|
|
icon="lock"
|
|
title="Este enlace ya no funciona"
|
|
description="Puede que lo hayan renovado o retirado. Pide uno nuevo a quien te lo dio."
|
|
/>
|
|
} @else if (shared.dashboard(); as d) {
|
|
<header class="ts-page-header">
|
|
<div>
|
|
<p class="ts-eyebrow">{{ d.projectKey }}</p>
|
|
<h1>{{ d.projectName }}</h1>
|
|
<p>
|
|
Estado del proyecto · actualizado a las {{ time(d.generatedAt) }} ·
|
|
se actualiza solo
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="ts-shared">
|
|
@if (d.flow; as flow) {
|
|
<section
|
|
class="ts-panel ts-shared__wide"
|
|
aria-labelledby="flow-title"
|
|
>
|
|
<header class="ts-panel__head">
|
|
<h2 id="flow-title">Flujo</h2>
|
|
<span class="ts-muted">{{ total() }} tickets</span>
|
|
</header>
|
|
@if (total()) {
|
|
<div
|
|
class="ts-flow__bar"
|
|
role="img"
|
|
[attr.aria-label]="flowLabel()"
|
|
>
|
|
@for (s of flow; track s.status) {
|
|
@if (s.count) {
|
|
<span
|
|
[style.--tone]="toneOf(s.color)"
|
|
[style.flex-grow]="s.count"
|
|
[attr.title]="s.name + ': ' + s.count"
|
|
></span>
|
|
}
|
|
}
|
|
</div>
|
|
<ul class="ts-flow__legend">
|
|
@for (s of flow; track s.status) {
|
|
<li
|
|
[style.--tone]="toneOf(s.color)"
|
|
[class.is-empty]="!s.count"
|
|
>
|
|
<span class="ts-flow__count">{{ s.count }}</span>
|
|
{{ s.name }}
|
|
</li>
|
|
}
|
|
</ul>
|
|
} @else {
|
|
<p class="ts-muted">Todavía no hay tickets.</p>
|
|
}
|
|
</section>
|
|
}
|
|
@if (d.blocks.includes('sprint')) {
|
|
@if (d.sprint; as sprint) {
|
|
<ts-sprint-pulse [sprint]="sprint" [linked]="false" />
|
|
} @else {
|
|
<section class="ts-panel">
|
|
<h2 class="ts-shared__title">Sprint</h2>
|
|
<p class="ts-muted">No hay un sprint en curso.</p>
|
|
</section>
|
|
}
|
|
}
|
|
@if (d.releases; as releases) {
|
|
@if (releases.length) {
|
|
<ts-release-forecast [releases]="releases" [linked]="false" />
|
|
} @else {
|
|
<section class="ts-panel">
|
|
<h2 class="ts-shared__title">Próximas releases</h2>
|
|
<p class="ts-muted">Ninguna planificada.</p>
|
|
</section>
|
|
}
|
|
}
|
|
@if (d.epics; as epics) {
|
|
<section class="ts-panel" aria-labelledby="epics-title">
|
|
<header class="ts-panel__head">
|
|
<h2 id="epics-title">Épicas</h2>
|
|
<span class="ts-muted">Abiertas, por fecha</span>
|
|
</header>
|
|
@if (epics.length) {
|
|
<ul class="ts-epics">
|
|
@for (e of epics; track e.key) {
|
|
<li>
|
|
<span class="ts-epics__title">{{ e.title }}</span>
|
|
<span class="ts-epics__meta"
|
|
>{{ e.done }}/{{ e.total }}
|
|
@if (e.dueDate) {
|
|
· {{ date(e.dueDate) }}
|
|
}
|
|
</span>
|
|
<span
|
|
class="ts-epics__bar"
|
|
role="progressbar"
|
|
aria-valuemin="0"
|
|
aria-valuemax="100"
|
|
[attr.aria-valuenow]="e.percent"
|
|
[attr.aria-label]="'Progreso de ' + e.title"
|
|
><span [style.width.%]="e.percent"></span
|
|
></span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
} @else {
|
|
<p class="ts-muted">Ninguna épica abierta.</p>
|
|
}
|
|
</section>
|
|
}
|
|
@if (d.activity; as activity) {
|
|
<ts-activity-heatmap [days]="activity" />
|
|
}
|
|
</div>
|
|
<p class="ts-shared__footer">Hecho con TaskStream</p>
|
|
} @else if (shared.error(); as error) {
|
|
<pi-empty-state
|
|
icon="exclamation"
|
|
title="No se pudo cargar"
|
|
[description]="error"
|
|
/>
|
|
} @else {
|
|
<pi-skeleton shape="block" height="12rem" />
|
|
}
|
|
`,
|
|
styles: `
|
|
:host {
|
|
display: grid;
|
|
gap: 1.5rem;
|
|
}
|
|
.ts-shared {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
|
|
gap: 1.25rem;
|
|
align-items: start;
|
|
}
|
|
.ts-shared__wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.ts-panel {
|
|
padding: 1.5rem;
|
|
}
|
|
.ts-panel__head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 1rem;
|
|
h2 {
|
|
margin: 0;
|
|
font-size: 1.125rem;
|
|
font-weight: 700;
|
|
}
|
|
.ts-muted {
|
|
font-size: 0.8125rem;
|
|
}
|
|
}
|
|
.ts-shared__title {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1.125rem;
|
|
font-weight: 700;
|
|
}
|
|
.ts-flow__bar {
|
|
display: flex;
|
|
gap: 3px;
|
|
height: 2rem;
|
|
span {
|
|
flex-basis: 0;
|
|
border-radius: 0.375rem;
|
|
background: var(--tone);
|
|
}
|
|
}
|
|
.ts-flow__legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem 1.25rem;
|
|
margin: 0.875rem 0 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
font-size: 0.875rem;
|
|
li {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
&::before {
|
|
content: '';
|
|
width: 0.625rem;
|
|
height: 0.625rem;
|
|
border-radius: 0.1875rem;
|
|
background: var(--tone);
|
|
}
|
|
&.is-empty {
|
|
color: var(--ts-muted);
|
|
}
|
|
}
|
|
}
|
|
.ts-flow__count {
|
|
font-family: var(--ts-font-mono);
|
|
font-weight: 600;
|
|
}
|
|
.ts-epics {
|
|
display: grid;
|
|
gap: 0.75rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
li {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 0.25rem 0.75rem;
|
|
}
|
|
}
|
|
.ts-epics__title {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 0.9375rem;
|
|
}
|
|
.ts-epics__meta {
|
|
color: var(--ts-muted);
|
|
font-family: var(--ts-font-mono);
|
|
font-size: 0.75rem;
|
|
}
|
|
.ts-epics__bar {
|
|
grid-column: 1 / -1;
|
|
height: 0.5rem;
|
|
border-radius: 0.25rem;
|
|
background: var(--ts-sunken);
|
|
overflow: hidden;
|
|
span {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--ts-brand);
|
|
}
|
|
}
|
|
.ts-shared__footer {
|
|
margin: 0;
|
|
color: var(--ts-muted);
|
|
font-size: 0.75rem;
|
|
text-align: center;
|
|
}
|
|
`,
|
|
providers: [provideSharedDashboardFacade()],
|
|
})
|
|
export class SharedDashboardPage {
|
|
/** Route param (`withComponentInputBinding`). */
|
|
readonly token = input.required<string>();
|
|
protected readonly shared = inject(SharedDashboardFacade);
|
|
|
|
protected readonly total = computed(() =>
|
|
(this.shared.dashboard()?.flow ?? []).reduce((n, s) => n + s.count, 0),
|
|
);
|
|
protected readonly flowLabel = computed(() =>
|
|
(this.shared.dashboard()?.flow ?? [])
|
|
.map((s) => `${s.name}: ${s.count}`)
|
|
.join(', '),
|
|
);
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
const token = this.token();
|
|
untracked(() => this.shared.open(token));
|
|
});
|
|
}
|
|
|
|
protected readonly toneOf = tone;
|
|
|
|
protected time(iso: string): string {
|
|
return new Date(iso).toLocaleTimeString('es', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
protected date(day: string): string {
|
|
return new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
timeZone: 'UTC',
|
|
});
|
|
}
|
|
}
|