feat: a dashboard for clients, behind a secret link
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>
This commit is contained in:
@@ -22,6 +22,7 @@ import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { TransferComponent } from '../ui/transfer';
|
||||
import { AutomationsComponent } from '../ui/automations';
|
||||
import { ClientShareComponent } from '../ui/client-share';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
CustomFieldType,
|
||||
@@ -58,7 +59,7 @@ const SECTIONS: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
hint: 'Quién ve el proyecto y si el público puede proponer cosas.',
|
||||
hint: 'Quién ve el proyecto, si el público puede proponer cosas y el panel para clientes.',
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
@@ -143,6 +144,7 @@ function idFrom(name: string): string {
|
||||
WebhooksComponent,
|
||||
TransferComponent,
|
||||
AutomationsComponent,
|
||||
ClientShareComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -257,6 +259,10 @@ function idFrom(name: string): string {
|
||||
}}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
@if (facade.canManage()) {
|
||||
<ts-client-share [projectId]="project.id" />
|
||||
}
|
||||
}
|
||||
|
||||
@if (draft(); as d) {
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,15 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/explore.page').then((m) => m.ExplorePage),
|
||||
},
|
||||
{
|
||||
// A project's dashboard for its clients: the token is the access.
|
||||
path: 'share/:token',
|
||||
title: 'Estado del proyecto · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/shared-dashboard.page').then(
|
||||
(m) => m.SharedDashboardPage,
|
||||
),
|
||||
},
|
||||
// The Spanish paths, from links shared before.
|
||||
{ path: 'cartera', redirectTo: 'portfolio' },
|
||||
{ path: 'explorar', redirectTo: 'explore' },
|
||||
|
||||
283
libs/web/tracker/presenters/src/lib/ui/client-share.ts
Normal file
283
libs/web/tracker/presenters/src/lib/ui/client-share.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { PiCheckboxComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
provideSharingFacade,
|
||||
SharingFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { SHARED_BLOCKS, SharedBlock } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** What each block shows, for whoever picks them. */
|
||||
const BLOCK_LABEL: Record<SharedBlock, { label: string; hint: string }> = {
|
||||
flow: { label: 'Flujo', hint: 'Cuántos tickets hay en cada estado' },
|
||||
sprint: { label: 'Sprint en curso', hint: 'Días que quedan y lo terminado' },
|
||||
releases: {
|
||||
label: 'Próximas releases',
|
||||
hint: 'Con su fecha probable al ritmo real',
|
||||
},
|
||||
epics: { label: 'Épicas', hint: 'Las abiertas, con su progreso' },
|
||||
activity: { label: 'Actividad', hint: 'Movimientos por día, 12 semanas' },
|
||||
};
|
||||
|
||||
/** What a new link shows until someone picks otherwise. */
|
||||
const DEFAULT_BLOCKS: SharedBlock[] = ['flow', 'sprint', 'releases'];
|
||||
|
||||
/**
|
||||
* The project's dashboard for clients: a read-only link, without an
|
||||
* account, showing only the blocks picked here.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-client-share',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiCheckboxComponent],
|
||||
providers: [provideSharingFacade()],
|
||||
host: { class: 'ts-panel', role: 'region', 'aria-labelledby': 'share-title' },
|
||||
template: `
|
||||
<div class="ts-share__head">
|
||||
<div>
|
||||
<h3 id="share-title">Panel para clientes</h3>
|
||||
<p class="ts-muted">
|
||||
Un enlace de solo lectura, sin cuenta, con los bloques que elijas. No
|
||||
enseña personas ni tickets sueltos (salvo las épicas).
|
||||
</p>
|
||||
</div>
|
||||
<label class="ts-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
[checked]="!!sharing.share()"
|
||||
[disabled]="!sharing.loaded() || sharing.busy()"
|
||||
(change)="toggle($any($event.target).checked)"
|
||||
/>
|
||||
<span class="ts-switch__track" aria-hidden="true"></span>
|
||||
<span>{{ sharing.share() ? 'Compartido' : 'Sin compartir' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (sharing.share(); as share) {
|
||||
<fieldset class="ts-share__blocks" [disabled]="sharing.busy()">
|
||||
<legend>Qué se ve</legend>
|
||||
@for (block of blocks; track block) {
|
||||
<div class="ts-share__block">
|
||||
<pi-checkbox
|
||||
[label]="label(block).label"
|
||||
[checked]="share.blocks.includes(block)"
|
||||
[disabled]="
|
||||
share.blocks.length === 1 && share.blocks.includes(block)
|
||||
"
|
||||
(checkedChange)="setBlock(block, $event)"
|
||||
/>
|
||||
<span class="ts-muted">{{ label(block).hint }}</span>
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
<div class="ts-share__link">
|
||||
<input
|
||||
class="ts-share__url"
|
||||
readonly
|
||||
aria-label="Enlace para clientes"
|
||||
[value]="url()"
|
||||
(focus)="$any($event.target).select()"
|
||||
/>
|
||||
<button type="button" class="ts-plain-link" (click)="copy()">
|
||||
Copiar
|
||||
</button>
|
||||
<a class="ts-plain-link" [href]="url()" target="_blank" rel="noopener"
|
||||
>Abrir</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
[disabled]="sharing.busy()"
|
||||
(click)="renew()"
|
||||
>
|
||||
Renovar enlace
|
||||
</button>
|
||||
</div>
|
||||
<p class="ts-muted ts-share__note">
|
||||
Se actualiza solo cada minuto. Renovar hace un enlace nuevo y el
|
||||
anterior deja de funcionar.
|
||||
</p>
|
||||
}
|
||||
@if (sharing.error(); as error) {
|
||||
<p class="ts-share__error" role="alert">{{ error }}</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-share__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
> div {
|
||||
flex: 1 1 26rem;
|
||||
}
|
||||
p {
|
||||
max-width: 46rem;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-share__blocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
legend {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
.ts-share__block {
|
||||
display: grid;
|
||||
.ts-muted {
|
||||
margin-left: 2.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.ts-share__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ts-share__url {
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--ts-line-strong);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-ink);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-share__note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-share__error {
|
||||
margin: 0;
|
||||
color: var(--ts-c-red);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
input:focus-visible + .ts-switch__track {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input:disabled ~ * {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.ts-switch__track {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 999px;
|
||||
background: var(--ts-line-strong);
|
||||
transition: background-color 0.15s ease;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-surface);
|
||||
box-shadow: var(--ts-shadow);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
.ts-switch input:checked + .ts-switch__track {
|
||||
background: var(--ts-brand);
|
||||
&::after {
|
||||
transform: translateX(1.125rem);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ClientShareComponent {
|
||||
readonly projectId = input.required<string>();
|
||||
protected readonly sharing = inject(SharingFacade);
|
||||
private readonly toast = inject(ToastService);
|
||||
protected readonly blocks = SHARED_BLOCKS;
|
||||
|
||||
protected readonly url = computed(() => {
|
||||
const share = this.sharing.share();
|
||||
return share ? `${location.origin}/share/${share.token}` : '';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.projectId();
|
||||
untracked(() => this.sharing.load(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected label(block: SharedBlock) {
|
||||
return BLOCK_LABEL[block];
|
||||
}
|
||||
|
||||
protected toggle(on: boolean): void {
|
||||
if (on) this.sharing.save(DEFAULT_BLOCKS);
|
||||
else this.sharing.remove();
|
||||
}
|
||||
|
||||
protected setBlock(block: SharedBlock, on: boolean): void {
|
||||
const current = this.sharing.share()?.blocks ?? [];
|
||||
const next = on ? [...current, block] : current.filter((b) => b !== block);
|
||||
if (next.length) this.sharing.save(next);
|
||||
}
|
||||
|
||||
protected renew(): void {
|
||||
const blocks = this.sharing.share()?.blocks;
|
||||
if (blocks) this.sharing.save(blocks, true);
|
||||
}
|
||||
|
||||
protected copy(): void {
|
||||
navigator.clipboard
|
||||
.writeText(this.url())
|
||||
.then(() => this.toast.success('Enlace copiado'))
|
||||
.catch(() => this.toast.error('No se pudo copiar'));
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,11 @@ export function verdictOf(release: ReleaseForecast): {
|
||||
<ul class="ts-issues ts-forecast">
|
||||
@for (row of rows(); track row.releaseId) {
|
||||
<li>
|
||||
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
||||
@if (linked()) {
|
||||
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
||||
} @else {
|
||||
<span class="ts-title">{{ row.name }}</span>
|
||||
}
|
||||
<span class="ts-verdict" [style.--tone]="row.tone">{{
|
||||
row.label
|
||||
}}</span>
|
||||
@@ -110,6 +114,8 @@ export function verdictOf(release: ReleaseForecast): {
|
||||
})
|
||||
export class ReleaseForecastComponent {
|
||||
readonly releases = input.required<ReleaseForecast[]>();
|
||||
/** False outside the app (the page for clients): no links into it. */
|
||||
readonly linked = input(true);
|
||||
|
||||
protected readonly rows = computed(() =>
|
||||
this.releases().map((r) => ({
|
||||
|
||||
@@ -52,7 +52,9 @@ const decimal = (n: number) =>
|
||||
@if (paceText(); as pace) {
|
||||
{{ pace }}
|
||||
}
|
||||
<a routerLink="../sprints">Ver el sprint →</a>
|
||||
@if (linked()) {
|
||||
<a routerLink="../sprints">Ver el sprint →</a>
|
||||
}
|
||||
</p>
|
||||
`,
|
||||
styles: `
|
||||
@@ -105,6 +107,8 @@ const decimal = (n: number) =>
|
||||
})
|
||||
export class SprintPulseComponent {
|
||||
readonly sprint = input.required<SprintPulse>();
|
||||
/** False outside the app (the page for clients): no links into it. */
|
||||
readonly linked = input(true);
|
||||
|
||||
protected readonly when = computed(
|
||||
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
|
||||
|
||||
Reference in New Issue
Block a user