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:
2026-10-16 10:39:26 +02:00
parent 4dc673997d
commit 8fe581ff3e
44 changed files with 3299 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
export * from './lib/notifications';
export * from './lib/facades/projects.facade';
export * from './lib/facades/calendar.facade';
export * from './lib/facades/sharing.facade';
export * from './lib/facades/push.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';

View File

@@ -0,0 +1,128 @@
import {
DestroyRef,
inject,
Injectable,
Provider,
signal,
} from '@angular/core';
import {
DashboardShare,
SharedBlock,
SharedDashboard,
SharingGateway,
} from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
import { describeError } from '../notifications';
/**
* The project's link for clients, in Configuración: turn it on, pick its
* blocks, renew or drop it. Seldom touched, so it lives in this facade
* alone, without a store.
*/
@Injectable()
export class SharingFacade {
private readonly gateway = inject(SharingGateway);
private projectId: string | null = null;
readonly share = signal<DashboardShare | null>(null);
readonly loaded = signal(false);
readonly busy = signal(false);
readonly error = signal<string | null>(null);
load(projectId: string): void {
this.projectId = projectId;
this.run(this.gateway.share(projectId));
}
/** On (or new blocks); `renew` makes a new secret, the old link dies. */
save(blocks: SharedBlock[], renew = false): void {
if (this.projectId)
this.run(this.gateway.save(this.projectId, blocks, renew));
}
remove(): void {
if (!this.projectId) return;
this.busy.set(true);
this.gateway.remove(this.projectId).subscribe({
next: () => this.done(null),
error: (error) => this.failed(error),
});
}
private run(call: Observable<DashboardShare | null>): void {
this.busy.set(true);
this.error.set(null);
call.subscribe({
next: (share) => this.done(share),
error: (error) => this.failed(error),
});
}
private done(share: DashboardShare | null): void {
this.share.set(share);
this.loaded.set(true);
this.busy.set(false);
}
private failed(error: unknown): void {
this.loaded.set(true);
this.busy.set(false);
this.error.set(describeError(error));
}
}
/** The link for clients, in Configuración: provided by the page. */
export function provideSharingFacade(): Provider[] {
return [SharingFacade];
}
/** Every minute: there is no live connection without a session. */
const REFRESH_MS = 60_000;
/** What a client sees through the link, read again every minute. */
@Injectable()
export class SharedDashboardFacade {
private readonly gateway = inject(SharingGateway);
private timer: ReturnType<typeof setInterval> | null = null;
readonly dashboard = signal<SharedDashboard | null>(null);
readonly loaded = signal(false);
/** No project has this link (never had, renewed or dropped). */
readonly missing = signal(false);
readonly error = signal<string | null>(null);
constructor() {
inject(DestroyRef).onDestroy(() => this.stop());
}
open(token: string): void {
this.stop();
this.read(token);
this.timer = setInterval(() => this.read(token), REFRESH_MS);
}
private read(token: string): void {
this.gateway.shared(token).subscribe({
next: (dashboard) => {
this.dashboard.set(dashboard);
this.missing.set(!dashboard);
this.error.set(null);
this.loaded.set(true);
},
error: (error) => {
this.error.set(describeError(error));
this.loaded.set(true);
},
});
}
private stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
}
/** The page for clients: provided by its route. */
export function provideSharedDashboardFacade(): Provider[] {
return [SharedDashboardFacade];
}

View File

@@ -14,3 +14,4 @@ export * from './lib/models/due';
export * from './lib/models/calendar';
export * from './lib/models/insights';
export * from './lib/models/home';
export * from './lib/models/sharing';

View File

@@ -0,0 +1,41 @@
import { ReleaseForecast, SprintPulse } from './insights';
/** The blocks a project can show to its clients, in the order they are shown. */
export const SHARED_BLOCKS = [
'flow',
'sprint',
'releases',
'epics',
'activity',
] as const;
export type SharedBlock = (typeof SHARED_BLOCKS)[number];
/** The project's link for clients: its secret and what it shows. */
export interface DashboardShare {
projectId: string;
token: string;
blocks: SharedBlock[];
createdAt: string;
}
/** What a client sees through the link (blocks not chosen come as null). */
export interface SharedDashboard {
projectKey: string;
projectName: string;
generatedAt: string;
blocks: SharedBlock[];
flow: { status: string; name: string; color: string; count: number }[] | null;
sprint: SprintPulse | null;
releases: ReleaseForecast[] | null;
epics:
| {
key: string;
title: string;
dueDate: string | null;
done: number;
total: number;
percent: number;
}[]
| null;
activity: { day: string; count: number }[] | null;
}

View File

@@ -1,5 +1,10 @@
import { Home } from '../models/home';
import { PortfolioProject, ProjectInsights } from '../models/insights';
import {
DashboardShare,
SharedBlock,
SharedDashboard,
} from '../models/sharing';
import { Observable } from 'rxjs';
import {
Activity,
@@ -368,6 +373,21 @@ export abstract class CalendarGateway {
abstract revoke(): Observable<void>;
}
/** The project's dashboard for clients, behind a secret link. */
export abstract class SharingGateway {
/** The project's link, if it has one (owners and admins). */
abstract share(projectId: string): Observable<DashboardShare | null>;
/** Turns it on or changes its blocks; `renew` makes a new secret. */
abstract save(
projectId: string,
blocks: SharedBlock[],
renew: boolean,
): Observable<DashboardShare>;
abstract remove(projectId: string): Observable<void>;
/** What a link shows, for anyone (null: no such link). */
abstract shared(token: string): Observable<SharedDashboard | null>;
}
export abstract class NotificationsGateway {
abstract mine(
unreadOnly: boolean,

View File

@@ -0,0 +1,48 @@
import { inject, Injectable } from '@angular/core';
import {
DashboardShare,
SharedBlock,
SharedDashboard,
SharingGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
DashboardShareDocument,
ShareDashboardDocument,
SharedDashboardDocument,
UnshareDashboardDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlSharingGateway extends SharingGateway {
private readonly gql = inject(GraphqlOperations);
share(projectId: string): Observable<DashboardShare | null> {
return this.gql
.query(DashboardShareDocument, { projectId })
.pipe(map((d) => (d.dashboardShare as DashboardShare | null) ?? null));
}
save(
projectId: string,
blocks: SharedBlock[],
renew: boolean,
): Observable<DashboardShare> {
return this.gql
.mutate(ShareDashboardDocument, { input: { projectId, blocks, renew } })
.pipe(map((d) => d.shareDashboard as DashboardShare));
}
remove(projectId: string): Observable<void> {
return this.gql
.mutate(UnshareDashboardDocument, { projectId })
.pipe(map(() => undefined));
}
shared(token: string): Observable<SharedDashboard | null> {
return this.gql
.query(SharedDashboardDocument, { token })
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
}
}

View File

@@ -329,6 +329,15 @@ export type SavedFilterCriteriaInput = {
unassigned?: boolean | null | undefined;
};
/** The project's link for clients */
export type ShareDashboardInput = {
/** Blocks to show: flow, sprint, releases, epics, activity */
blocks: Array<string>;
projectId: string;
/** A new secret: the old link stops working */
renew?: boolean | null | undefined;
};
/** PLANNED, ACTIVE (one per project at a time) or CLOSED */
export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
@@ -2437,6 +2446,98 @@ export type DeleteMyViewMutationVariables = Exact<{
export type DeleteMyViewMutation = { deleteMyView: boolean };
export type DashboardShareFieldsFragment = {
projectId: string;
token: string;
blocks: Array<string>;
createdAt: string;
};
export type DashboardShareQueryVariables = Exact<{
projectId: string;
}>;
export type DashboardShareQuery = {
dashboardShare: {
projectId: string;
token: string;
blocks: Array<string>;
createdAt: string;
} | null;
};
export type ShareDashboardMutationVariables = Exact<{
input: ShareDashboardInput;
}>;
export type ShareDashboardMutation = {
shareDashboard: {
projectId: string;
token: string;
blocks: Array<string>;
createdAt: string;
};
};
export type UnshareDashboardMutationVariables = Exact<{
projectId: string;
}>;
export type UnshareDashboardMutation = { unshareDashboard: boolean };
export type SharedDashboardQueryVariables = Exact<{
token: string;
}>;
export type SharedDashboardQuery = {
sharedDashboard: {
projectKey: string;
projectName: string;
generatedAt: string;
blocks: Array<string>;
flow: Array<{
status: string;
name: string;
color: string;
count: number;
}> | null;
sprint: {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
behind: boolean;
} | null;
releases: Array<{
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
likelyDate: string | null;
safeDate: string | null;
verdict: string;
}> | null;
epics: Array<{
key: string;
title: string;
dueDate: string | null;
done: number;
total: number;
percent: number;
}> | null;
activity: Array<{ day: string; count: number }> | null;
} | null;
};
export type SprintFieldsFragment = {
id: string;
projectId: string;
@@ -3948,6 +4049,28 @@ export const SavedFilterFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
export const DashboardShareFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'DashboardShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'DashboardShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<DashboardShareFieldsFragment, unknown>;
export const SprintFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -14377,6 +14500,391 @@ export const DeleteMyViewDocument = {
DeleteMyViewMutation,
DeleteMyViewMutationVariables
>;
export const DashboardShareDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'DashboardShare' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'dashboardShare' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'DashboardShareFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'DashboardShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'DashboardShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<DashboardShareQuery, DashboardShareQueryVariables>;
export const ShareDashboardDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'ShareDashboard' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'ShareDashboardInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'shareDashboard' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'DashboardShareFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'DashboardShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'DashboardShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<
ShareDashboardMutation,
ShareDashboardMutationVariables
>;
export const UnshareDashboardDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnshareDashboard' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unshareDashboard' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
UnshareDashboardMutation,
UnshareDashboardMutationVariables
>;
export const SharedDashboardDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'SharedDashboard' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'token' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sharedDashboard' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'token' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'token' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectName' } },
{ kind: 'Field', name: { kind: 'Name', value: 'generatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'flow' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'status' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'color' } },
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sprintId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'startDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'totalDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'daysLeft' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'tickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'doneTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'points' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'donePoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pacePerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'neededPerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releases' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'targetDate' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'likelyDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'safeDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'verdict' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'epics' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'dueDate' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'percent' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'activity' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
SharedDashboardQuery,
SharedDashboardQueryVariables
>;
export const SprintsDocument = {
kind: 'Document',
definitions: [

View File

@@ -0,0 +1,74 @@
fragment DashboardShareFields on DashboardShare {
projectId
token
blocks
createdAt
}
query DashboardShare($projectId: ID!) {
dashboardShare(projectId: $projectId) {
...DashboardShareFields
}
}
mutation ShareDashboard($input: ShareDashboardInput!) {
shareDashboard(input: $input) {
...DashboardShareFields
}
}
mutation UnshareDashboard($projectId: ID!) {
unshareDashboard(projectId: $projectId)
}
query SharedDashboard($token: String!) {
sharedDashboard(token: $token) {
projectKey
projectName
generatedAt
blocks
flow {
status
name
color
count
}
sprint {
sprintId
name
startDate
endDate
totalDays
daysLeft
tickets
doneTickets
points
donePoints
pacePerDay
neededPerDay
behind
}
releases {
releaseId
name
targetDate
total
finished
likelyDate
safeDate
verdict
}
epics {
key
title
dueDate
done
total
percent
}
activity {
day
count
}
}
}

View File

@@ -20,6 +20,7 @@ import {
WebhooksGateway,
TransferGateway,
AutomationsGateway,
SharingGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -44,6 +45,7 @@ import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
import { GraphqlSharingGateway } from './adapters/graphql-sharing.gateway';
/**
* The adapters the app shell needs from the start (the command palette, the
@@ -79,5 +81,6 @@ export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
{ provide: TransferGateway, useClass: HttpTransferGateway },
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
{ provide: SharingGateway, useClass: GraphqlSharingGateway },
]);
}

View File

@@ -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) {

View File

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

View File

@@ -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' },

View 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'));
}
}

View File

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

View File

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