Compare commits

..

3 Commits

Author SHA1 Message Date
8fe581ff3e 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>
2026-10-16 10:39:26 +02:00
4dc673997d feat(web): the project dashboard on the team's TV
«Modo pantalla» puts the dashboard full screen, over the rest of the
app: one block at a time and larger (the figures and the flow first),
moving on every 15 seconds, with the project, the time and the live
data. ← and → move, the space bar pauses and Esc leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:41:59 +02:00
1101b6e926 feat: dashboard maps the activity, follows a ticket's way and the proposals
Three more blocks on the project dashboard, also by REST, GraphQL and
MCP:
- Activity: what moved each day of the last 12 weeks, in a heatmap,
  with the busiest weekday and the longest lull.
- A ticket's way: for the tickets done in the last 90 days, where the
  time from created to done went, status by status (waiting included),
  and how much of it was work (flow efficiency).
- The public's proposals: received, waiting, under way, done and
  discarded, and the most voted still unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:29:38 +02:00
56 changed files with 4969 additions and 84 deletions

View File

@@ -55,7 +55,7 @@ describe('task-stream (e2e)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
async function gql<T = Record<string, any>>( async function gql<T = Record<string, any>>(
token: string, token: string | null,
query: string, query: string,
variables?: object, variables?: object,
): Promise<T> { ): Promise<T> {
@@ -63,7 +63,7 @@ describe('task-stream (e2e)', () => {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
authorization: `Bearer ${token}`, ...(token ? { authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
}); });
@@ -1415,6 +1415,16 @@ describe('task-stream (e2e)', () => {
); );
expect(project.insights.risks.overdueCount).toBe(1); expect(project.insights.risks.overdueCount).toBe(1);
// Activity per day (today's creations among it), the journey, no proposals.
const today = new Date().toISOString().slice(0, 10);
expect(insights.body['heatmap']).toHaveLength(84);
expect(
insights.body['heatmap'].find((d: { day: string }) => d.day === today)
?.count,
).toBeGreaterThan(0);
expect(insights.body['journey']).toMatchObject({ samples: 0, steps: [] });
expect(insights.body['proposals']).toBeNull();
// What changed since a moment, and what the backlog lacks. // What changed since a moment, and what the backlog lacks.
const since = new Date(Date.now() - 60_000).toISOString(); const since = new Date(Date.now() - 60_000).toISOString();
const later = await rest( const later = await rest(
@@ -1905,6 +1915,118 @@ describe('task-stream (e2e)', () => {
); );
}); });
it('shares a dashboard with clients through a secret link', async () => {
const key = 'K' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', {
key,
name: `Cliente ${key}`,
});
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
role: 'MEMBER',
});
const epic = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Pagos',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${epic}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Parte',
parent: epic,
});
expect(
(await rest('token-ana', 'GET', `/projects/${key}/share`)).status,
).toBe(204);
// Only known blocks are kept; a member cannot share.
const made = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['epics', 'flow', 'nope'],
});
expect(made.status).toBe(200);
expect(made.body['blocks']).toEqual(['flow', 'epics']);
const token = made.body['token'] as string;
expect(token.length).toBeGreaterThan(30);
expect(
(
await rest('token-bob', 'PUT', `/projects/${key}/share`, {
blocks: ['flow'],
})
).status,
).toBe(403);
// Anyone with the link sees the chosen blocks, nothing else.
const seen = await eventually(
() => rest(null, 'GET', `/shared/dashboards/${token}`),
(r) => r.status === 200 && r.body['epics']?.[0]?.total === 1,
);
expect(seen.body).toMatchObject({
projectKey: key,
sprint: null,
releases: null,
activity: null,
});
expect(
seen.body['flow'].reduce(
(n: number, f: { count: number }) => n + f.count,
0,
),
).toBe(2);
expect(seen.body['epics'][0]).toMatchObject({
key: epic,
title: 'Pagos',
done: 0,
});
const viaGql = await gql(
null,
`query($t: String!) { sharedDashboard(token: $t) { projectKey blocks } }`,
{ t: token },
);
expect(viaGql.sharedDashboard).toEqual({
projectKey: key,
blocks: ['flow', 'epics'],
});
// Changing blocks keeps the link; renewing it kills the old one.
const changed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['activity'],
});
expect(changed.body['token']).toBe(token);
const renewed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['activity'],
renew: true,
});
expect(renewed.body['token']).not.toBe(token);
expect(
(await rest(null, 'GET', `/shared/dashboards/${token}`)).status,
).toBe(404);
const fresh = await rest(
null,
'GET',
`/shared/dashboards/${renewed.body['token']}`,
);
expect(fresh.body['activity']).toHaveLength(84);
expect(fresh.body['flow']).toBeNull();
// Off: the link stops working.
expect(
(await rest('token-ana', 'DELETE', `/projects/${key}/share`)).status,
).toBe(204);
expect(
(await rest(null, 'GET', `/shared/dashboards/${renewed.body['token']}`))
.status,
).toBe(404);
});
it('fills the home page: agenda, activity, the week and who is here', async () => { it('fills the home page: agenda, activity, the week and who is here', async () => {
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase(); const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key }); await rest('token-ana', 'POST', '/projects', { key, name: key });

View File

@@ -10680,6 +10680,324 @@
] ]
} }
}, },
"/api/projects/{project}/share": {
"get": {
"operationId": "SharingController_share",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardShareDto"
}
}
}
},
"204": {
"description": "It has none"
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "The project's link for clients, if any",
"tags": [
"Projects"
]
},
"put": {
"operationId": "SharingController_save",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ShareDashboardDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DashboardShareDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Turn the link on or change its blocks (renew: a new secret)",
"tags": [
"Projects"
]
},
"delete": {
"operationId": "SharingController_remove",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "The link stops working",
"tags": [
"Projects"
]
}
},
"/api/shared/dashboards/{token}": {
"get": {
"description": "Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.",
"operationId": "SharingController_shared",
"parameters": [
{
"name": "token",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedDashboardDto"
}
}
}
}
},
"summary": "What a link for clients shows (no session)",
"tags": [
"Projects"
],
"security": []
}
},
"/api/projects/{project}/export.csv": { "/api/projects/{project}/export.csv": {
"get": { "get": {
"description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.", "description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.",
@@ -13403,6 +13721,142 @@
"none" "none"
] ]
}, },
"ActivityDayDto": {
"type": "object",
"properties": {
"day": {
"type": "string",
"example": "2026-10-15"
},
"count": {
"type": "number"
}
},
"required": [
"day",
"count"
]
},
"JourneyStepDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Status id of the project settings"
},
"days": {
"type": "number",
"description": "Average days a closed ticket spent in it"
},
"share": {
"type": "number",
"description": "Its part of the whole lead time, 0-100"
}
},
"required": [
"status",
"days",
"share"
]
},
"JourneyDto": {
"type": "object",
"properties": {
"samples": {
"type": "number",
"description": "Tickets done in the last 90 days"
},
"leadDays": {
"type": "number",
"nullable": true
},
"flowEfficiency": {
"type": "number",
"nullable": true,
"description": "Part of the lead time in an in-progress status, 0-100"
},
"steps": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JourneyStepDto"
}
}
},
"required": [
"samples",
"leadDays",
"flowEfficiency",
"steps"
]
},
"WaitingProposalDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"title": {
"type": "string"
},
"votes": {
"type": "number"
},
"days": {
"type": "number",
"description": "Days since it came in"
}
},
"required": [
"id",
"key",
"title",
"votes",
"days"
]
},
"ProposalsFunnelDto": {
"type": "object",
"properties": {
"received": {
"type": "number"
},
"receivedLately": {
"type": "number",
"description": "In the last 30 days"
},
"waiting": {
"type": "number",
"description": "Open, not started, in no sprint nor release"
},
"underWay": {
"type": "number"
},
"done": {
"type": "number"
},
"discarded": {
"type": "number"
},
"topWaiting": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WaitingProposalDto"
}
}
},
"required": [
"received",
"receivedLately",
"waiting",
"underWay",
"done",
"discarded",
"topWaiting"
]
},
"ProjectInsightsDto": { "ProjectInsightsDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -13464,6 +13918,26 @@
"items": { "items": {
"$ref": "#/components/schemas/FieldBreakdownDto" "$ref": "#/components/schemas/FieldBreakdownDto"
} }
},
"heatmap": {
"description": "The last 12 weeks from a Monday, oldest first (84 days)",
"type": "array",
"items": {
"$ref": "#/components/schemas/ActivityDayDto"
}
},
"journey": {
"$ref": "#/components/schemas/JourneyDto"
},
"proposals": {
"nullable": true,
"description": "Null while the project has never had a proposal",
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/ProposalsFunnelDto"
}
]
} }
}, },
"required": [ "required": [
@@ -13477,7 +13951,10 @@
"team", "team",
"backlog", "backlog",
"quality", "quality",
"fields" "fields",
"heatmap",
"journey",
"proposals"
] ]
}, },
"ActivityDto": { "ActivityDto": {
@@ -16126,6 +16603,186 @@
} }
} }
}, },
"DashboardShareDto": {
"type": "object",
"properties": {
"projectId": {
"type": "string"
},
"token": {
"type": "string",
"description": "Secret of the link /share/{token}"
},
"blocks": {
"example": [
"flow",
"sprint",
"releases"
],
"type": "array",
"items": {
"type": "string"
}
},
"createdAt": {
"type": "string"
}
},
"required": [
"projectId",
"token",
"blocks",
"createdAt"
]
},
"ShareDashboardDto": {
"type": "object",
"properties": {
"blocks": {
"example": [
"flow",
"sprint",
"releases"
],
"description": "Blocks to show: flow, sprint, releases, epics, activity",
"type": "array",
"items": {
"type": "string"
}
},
"renew": {
"type": "boolean",
"description": "A new secret: the old link stops working"
}
},
"required": [
"blocks"
]
},
"SharedFlowDto": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"name": {
"type": "string"
},
"color": {
"type": "string",
"description": "A colour name of the project settings"
},
"count": {
"type": "number"
}
},
"required": [
"status",
"name",
"color",
"count"
]
},
"SharedEpicDto": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"title": {
"type": "string"
},
"dueDate": {
"type": "string",
"nullable": true
},
"done": {
"type": "number"
},
"total": {
"type": "number"
},
"percent": {
"type": "number"
}
},
"required": [
"key",
"title",
"dueDate",
"done",
"total",
"percent"
]
},
"SharedDashboardDto": {
"type": "object",
"properties": {
"projectKey": {
"type": "string"
},
"projectName": {
"type": "string"
},
"generatedAt": {
"type": "string"
},
"blocks": {
"type": "array",
"items": {
"type": "string"
}
},
"flow": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/SharedFlowDto"
}
},
"sprint": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/SprintPulseDto"
}
]
},
"releases": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/ReleaseForecastDto"
}
},
"epics": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/SharedEpicDto"
}
},
"activity": {
"nullable": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/ActivityDayDto"
}
}
},
"required": [
"projectKey",
"projectName",
"generatedAt",
"blocks",
"flow",
"sprint",
"releases",
"epics",
"activity"
]
},
"ImportLineDto": { "ImportLineDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -13,6 +13,14 @@ type Activity {
type: String! type: String!
} }
"""How much happened on one day"""
type ActivityDay {
count: Int!
"""YYYY-MM-DD"""
day: String!
}
input AddChecklistItemInput { input AddChecklistItemInput {
issueId: ID! issueId: ID!
text: String! text: String!
@@ -457,6 +465,16 @@ input CustomFieldValueInput {
value: String value: String
} }
"""The project's link for clients (owners and admins)"""
type DashboardShare {
blocks: [String!]!
createdAt: String!
projectId: ID!
"""Secret of the link /share/{token}"""
token: String!
}
"""A commit, branch or pull request that mentions the ticket""" """A commit, branch or pull request that mentions the ticket"""
type DevLink { type DevLink {
at: String! at: String!
@@ -815,6 +833,28 @@ input IssueTypeOptionInput {
template: IssueTemplateInput template: IssueTemplateInput
} }
"""
Where the time of the tickets done in the last 90 days went, created to done
"""
type Journey {
"""Part of the lead time in an in-progress status, 0-100"""
flowEfficiency: Int
leadDays: Float
samples: Int!
steps: [JourneyStep!]!
}
type JourneyStep {
"""Average days a closed ticket spent in it"""
days: Float!
"""Its part of the whole lead time, 0-100"""
share: Int!
"""Status id of the project settings"""
status: String!
}
enum LinkDirection { enum LinkDirection {
INWARD INWARD
OUTWARD OUTWARD
@@ -980,6 +1020,9 @@ type Mutation {
"""Put a ticket in a sprint (null: back to the backlog)""" """Put a ticket in a sprint (null: back to the backlog)"""
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult! setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
"""Turn the link on or change its blocks; renew for a new secret"""
shareDashboard(input: ShareDashboardInput!): DashboardShare!
"""Start a planned sprint (only one under way per project)""" """Start a planned sprint (only one under way per project)"""
startSprint(sprintId: ID!): CommandResult! startSprint(sprintId: ID!): CommandResult!
@@ -1003,6 +1046,9 @@ type Mutation {
unarchiveProject(projectId: ID!): CommandResult! unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""The link stops working"""
unshareDashboard(projectId: ID!): Boolean!
"""Push off on this device""" """Push off on this device"""
unsubscribePush(endpoint: String!): Boolean! unsubscribePush(endpoint: String!): Boolean!
@@ -1283,6 +1329,13 @@ type ProjectInsights {
"""One per active list custom field""" """One per active list custom field"""
fields: [FieldBreakdown!]! fields: [FieldBreakdown!]!
generatedAt: String! generatedAt: String!
"""The last 12 weeks from a Monday, oldest first (84 days)"""
heatmap: [ActivityDay!]!
journey: Journey!
"""Null while the project has never had a proposal"""
proposals: ProposalsFunnel
quality: Quality! quality: Quality!
releases: [ReleaseForecast!]! releases: [ReleaseForecast!]!
risks: Risks! risks: Risks!
@@ -1361,6 +1414,23 @@ type ProjectStats {
total: Int! total: Int!
} }
"""The public's proposals, from received to done or discarded"""
type ProposalsFunnel {
discarded: Int!
done: Int!
received: Int!
"""In the last 30 days"""
receivedLately: Int!
"""Most voted waiting, at most 5"""
topWaiting: [WaitingProposal!]!
underWay: Int!
"""Open, not started, in no sprint nor release"""
waiting: Int!
}
"""A browser's Web Push subscription (PushSubscription.toJSON())""" """A browser's Web Push subscription (PushSubscription.toJSON())"""
input PushSubscriptionInput { input PushSubscriptionInput {
auth: String! auth: String!
@@ -1402,6 +1472,9 @@ type Query {
): [Activity!]! ): [Activity!]!
automations(projectId: ID!): [Automation!]! automations(projectId: ID!): [Automation!]!
"""The project's link for clients, if any (owners and admins)"""
dashboardShare(projectId: ID!): DashboardShare
""" """
A project's epics: top-level tickets that group others, with the progress of those A project's epics: top-level tickets that group others, with the progress of those
""" """
@@ -1478,6 +1551,9 @@ type Query {
"""My views of the project's board, then the shared ones""" """My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]! savedFilters(projectId: ID!): [SavedFilter!]!
"""What a link for clients shows, for anyone with it"""
sharedDashboard(token: String!): SharedDashboard
sprint(id: ID!): Sprint! sprint(id: ID!): Sprint!
""" """
@@ -1653,6 +1729,50 @@ input SavedFilterCriteriaInput {
unassigned: Boolean unassigned: Boolean
} }
"""The project's link for clients"""
input ShareDashboardInput {
"""Blocks to show: flow, sprint, releases, epics, activity"""
blocks: [String!]!
projectId: ID!
"""A new secret: the old link stops working"""
renew: Boolean
}
"""
What a link for clients shows: only the chosen blocks (the others are null)
"""
type SharedDashboard {
activity: [ActivityDay!]
blocks: [String!]!
epics: [SharedEpic!]
flow: [SharedFlow!]
generatedAt: String!
projectKey: String!
projectName: String!
releases: [ReleaseForecast!]
sprint: SprintPulse
}
"""An open epic and how far it got"""
type SharedEpic {
done: Int!
dueDate: String
key: String!
percent: Int!
title: String!
total: Int!
}
"""Tickets in one status"""
type SharedFlow {
"""A colour name of the project settings"""
color: String!
count: Int!
name: String!
status: String!
}
"""A time-boxed iteration: the tickets the team commits to""" """A time-boxed iteration: the tickets the team commits to"""
type Sprint { type Sprint {
burndown: SprintBurndown! burndown: SprintBurndown!
@@ -1914,6 +2034,15 @@ type UserRef {
name: String! name: String!
} }
type WaitingProposal {
"""Days since it came in"""
days: Int!
id: ID!
key: String!
title: String!
votes: Int!
}
""" """
Where the project tells another system what happened (signed POST, HMAC-SHA256) Where the project tells another system what happened (signed POST, HMAC-SHA256)
""" """

View File

@@ -1636,6 +1636,45 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/projects/{project}/share": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The project's link for clients, if any */
get: operations["SharingController_share"];
/** Turn the link on or change its blocks (renew: a new secret) */
put: operations["SharingController_save"];
post?: never;
/** The link stops working */
delete: operations["SharingController_remove"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/shared/dashboards/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* What a link for clients shows (no session)
* @description Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.
*/
get: operations["SharingController_shared"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/export.csv": { "/api/projects/{project}/export.csv": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2441,6 +2480,46 @@ export interface components {
/** @description Open tickets with no value */ /** @description Open tickets with no value */
none: number; none: number;
}; };
ActivityDayDto: {
/** @example 2026-10-15 */
day: string;
count: number;
};
JourneyStepDto: {
/** @description Status id of the project settings */
status: string;
/** @description Average days a closed ticket spent in it */
days: number;
/** @description Its part of the whole lead time, 0-100 */
share: number;
};
JourneyDto: {
/** @description Tickets done in the last 90 days */
samples: number;
leadDays: number | null;
/** @description Part of the lead time in an in-progress status, 0-100 */
flowEfficiency: number | null;
steps: components["schemas"]["JourneyStepDto"][];
};
WaitingProposalDto: {
id: string;
key: string;
title: string;
votes: number;
/** @description Days since it came in */
days: number;
};
ProposalsFunnelDto: {
received: number;
/** @description In the last 30 days */
receivedLately: number;
/** @description Open, not started, in no sprint nor release */
waiting: number;
underWay: number;
done: number;
discarded: number;
topWaiting: components["schemas"]["WaitingProposalDto"][];
};
ProjectInsightsDto: { ProjectInsightsDto: {
generatedAt: string; generatedAt: string;
sprint: components["schemas"]["SprintPulseDto"] | null; sprint: components["schemas"]["SprintPulseDto"] | null;
@@ -2455,6 +2534,11 @@ export interface components {
quality: components["schemas"]["QualityDto"]; quality: components["schemas"]["QualityDto"];
/** @description One per active list custom field */ /** @description One per active list custom field */
fields: components["schemas"]["FieldBreakdownDto"][]; fields: components["schemas"]["FieldBreakdownDto"][];
/** @description The last 12 weeks from a Monday, oldest first (84 days) */
heatmap: components["schemas"]["ActivityDayDto"][];
journey: components["schemas"]["JourneyDto"];
/** @description Null while the project has never had a proposal */
proposals: components["schemas"]["ProposalsFunnelDto"] | null;
}; };
ActivityDto: { ActivityDto: {
id: string; id: string;
@@ -3379,6 +3463,59 @@ export interface components {
/** @description Paused webhooks get nothing */ /** @description Paused webhooks get nothing */
active?: boolean; active?: boolean;
}; };
DashboardShareDto: {
projectId: string;
/** @description Secret of the link /share/{token} */
token: string;
/**
* @example [
* "flow",
* "sprint",
* "releases"
* ]
*/
blocks: string[];
createdAt: string;
};
ShareDashboardDto: {
/**
* @description Blocks to show: flow, sprint, releases, epics, activity
* @example [
* "flow",
* "sprint",
* "releases"
* ]
*/
blocks: string[];
/** @description A new secret: the old link stops working */
renew?: boolean;
};
SharedFlowDto: {
status: string;
name: string;
/** @description A colour name of the project settings */
color: string;
count: number;
};
SharedEpicDto: {
key: string;
title: string;
dueDate: string | null;
done: number;
total: number;
percent: number;
};
SharedDashboardDto: {
projectKey: string;
projectName: string;
generatedAt: string;
blocks: string[];
flow: components["schemas"]["SharedFlowDto"][] | null;
sprint: components["schemas"]["SprintPulseDto"] | null;
releases: components["schemas"]["ReleaseForecastDto"][] | null;
epics: components["schemas"]["SharedEpicDto"][] | null;
activity: components["schemas"]["ActivityDayDto"][] | null;
};
ImportLineDto: { ImportLineDto: {
/** @description Line of the file (the header is 1) */ /** @description Line of the file (the header is 1) */
line: number; line: number;
@@ -10836,6 +10973,246 @@ export interface operations {
}; };
}; };
}; };
SharingController_share: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DashboardShareDto"];
};
};
/** @description It has none */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_save: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ShareDashboardDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DashboardShareDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_remove: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_shared: {
parameters: {
query?: never;
header?: never;
path: {
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SharedDashboardDto"];
};
};
};
};
TransferController_exportCsv: { TransferController_exportCsv: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -253,7 +253,7 @@ export class ProjectsResolver {
@Tool({ @Tool({
name: 'get_project_insights', name: 'get_project_insights',
description: description:
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) and open tickets per option of each list custom field. With since, also what changed after it.", "The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) open tickets per option of each list custom field, activity per day over the last 12 weeks, where the time of the tickets done lately went from created to done (days and share per status, and the part spent at work: flow efficiency) and, if the public has proposed anything, the proposals funnel (received, waiting, under way, done, discarded, most voted waiting). With since, also what changed after it.",
paramsSchema: z.object({ paramsSchema: z.object({
project: projectRef, project: projectRef,
since: z since: z

View File

@@ -24,6 +24,7 @@ describe('appRoutes', () => {
'', '',
'portfolio', 'portfolio',
'explore', 'explore',
'share/:token',
'cartera', 'cartera',
'explorar', 'explorar',
'p/:key', 'p/:key',
@@ -31,10 +32,12 @@ describe('appRoutes', () => {
expect(children[0].canActivate).toContain(authGuard); expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toContain(authGuard); expect(children[1].canActivate).toContain(authGuard);
expect(children[2].canActivate).toBeUndefined(); expect(children[2].canActivate).toBeUndefined();
expect(children[5].canActivate).toBeUndefined(); // The page for clients needs no session: its link is the key.
expect(children[3].canActivate).toBeUndefined();
expect(children[6].canActivate).toBeUndefined();
// The Spanish paths of shared links lead to the English ones. // The Spanish paths of shared links lead to the English ones.
expect(children[3].redirectTo).toBe('portfolio'); expect(children[4].redirectTo).toBe('portfolio');
expect(children[4].redirectTo).toBe('explore'); expect(children[5].redirectTo).toBe('explore');
}); });
it('gives each page its own state, not the root', async () => { it('gives each page its own state, not the root', async () => {

View File

@@ -339,6 +339,25 @@ Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea,
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
fechas y títulos que ya están en el read model. fechas y títulos que ya están en el read model.
### Panel para clientes
Cada proyecto puede enseñar su estado a sus clientes con un enlace de solo lectura, sin cuenta: `/share/{token}` en la
web, que lee `GET /api/shared/dashboards/{token}` (GraphQL: `sharedDashboard(token)`), ambos sin sesión. Solo trae los
bloques elegidos, y los demás llegan a `null`:
| Bloque | Qué da |
| ---------- | -------------------------------------------------------------------------- |
| `flow` | Tickets en cada estado activo, con su nombre y color |
| `sprint` | El sprint en curso, como en el panel (¿llega?) |
| `releases` | Las releases planificadas con su previsión al ritmo real |
| `epics` | Las épicas abiertas (8, por fecha): título, fecha, terminados y porcentaje |
| `activity` | Movimientos por día de las últimas 12 semanas |
No enseña personas ni tickets sueltos (salvo las épicas). Propietarios y admins lo activan, cambian sus bloques o lo
renuevan con `GET`, `PUT` y `DELETE /api/projects/{project}/share` (`{ blocks, renew }`; GraphQL: `dashboardShare`,
`shareDashboard`, `unshareDashboard`). Renovarlo deja sin servicio el enlace anterior. El token (32 bytes aleatorios)
se guarda tal cual en `dashboard_shares` para poder volver a copiar el enlace, y se borra con el proyecto.
### Notificaciones ### Notificaciones
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
@@ -452,6 +471,9 @@ decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días | | Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
| Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases | | Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases |
| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor | | Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor |
| Actividad | Movimientos por día (todo lo de la actividad) de las últimas 12 semanas, desde un lunes: 84 días, en `heatmap` |
| Recorrido | De los tickets terminados en 90 días: días de media de creado a hecho, días y parte del total en cada estado por el que pasaron (también la espera en Backlog o Por hacer) y `flowEfficiency`, la parte en un estado «en curso» |
| Propuestas | Si el público ha propuesto algo (tickets con la etiqueta «propuesta»): recibidas (y en 30 días), esperando (sin empezar ni planificar), en marcha, hechas y descartadas, y las 5 más votadas que esperan; si no, `null` |
## 3.2 GraphQL ## 3.2 GraphQL
@@ -704,6 +726,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | | Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | | Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |

View File

@@ -129,8 +129,9 @@ quieta con la misma caja, así que nada se mueve al llegar. Lo demás es de su p
- Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los - Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los
`providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`, `providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`,
`provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`): cargan con la página y el inyector de `provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`, `provideSharedDashboardFacade()`): cargan con la página y el inyector de
la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga. la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga.
- `SharingFacade` (el enlace para clientes en Configuración) va en los `providers` de su propio componente.
- Las de un proyecto o un ticket se registran con `provideProjectFacade()`, `provideBoardFacade()` o - Las de un proyecto o un ticket se registran con `provideProjectFacade()`, `provideBoardFacade()` o
`provideIssueFacade()` en los `providers` del componente de la página (store + facade) y desaparecen al salir (con sus `provideIssueFacade()` en los `providers` del componente de la página (store + facade) y desaparecen al salir (con sus
subscriptions). Las páginas hijas de `/p/:key` reciben la misma `ProjectFacade`. subscriptions). Las páginas hijas de `/p/:key` reciben la misma `ProjectFacade`.
@@ -296,24 +297,25 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar». Debajo, mis proyectos con su actividad | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar». Debajo, mis proyectos con su actividad |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor, el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | | `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | | `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de

View File

@@ -34,56 +34,56 @@ flowchart LR
60 herramientas, agrupadas por ámbito: 60 herramientas, agrupadas por ámbito:
| Ámbito | Herramienta | Qué hace | | Ámbito | Herramienta | Qué hace |
| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuario | `whoami` | El usuario del token | | Usuario | `whoami` | El usuario del token |
| | `search_users` | Buscar personas de Pireon (para asignar) | | | `search_users` | Buscar personas de Pireon (para asignar) |
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | | | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
| | `mark_notifications_read` | Marcar como leídas unas o todas | | | `mark_notifications_read` | Marcar como leídas unas o todas |
| Proyectos | `list_my_projects` | Mis proyectos | | Proyectos | `list_my_projects` | Mis proyectos |
| | `list_public_projects` | Proyectos públicos | | | `list_public_projects` | Proyectos públicos |
| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) | | | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) |
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | | | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar | | | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) | | | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
| | `get_project_stats` | Cifras de seguimiento | | | `get_project_stats` | Cifras de seguimiento |
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | | | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas | | | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas |
| | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger | | | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger |
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios | | | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios |
| | `get_project_activity` | Actividad reciente | | | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | | | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) | | | `create_issue` | Crear un ticket (también colgado de una épica) |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | | | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | | | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
| | `assign_issue` | Asignar o desasignar | | | `assign_issue` | Asignar o desasignar |
| | `set_issue_release` | Meter en una release o sacar | | | `set_issue_release` | Meter en una release o sacar |
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | | | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | | | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo | | | `watch_issue` | Seguir un ticket o dejar de seguirlo |
| | `vote_issue` | Votar un ticket o quitar el voto | | | `vote_issue` | Votar un ticket o quitar el voto |
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | | | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | | | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | | | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
| | `rank_issue` | Mover un ticket en el backlog | | | `rank_issue` | Mover un ticket en el backlog |
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | | | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | | | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
| Sprints | `list_sprints` | Sprints de un proyecto | | Sprints | `list_sprints` | Sprints de un proyecto |
| | `get_sprint` | Progreso, tickets y burndown | | | `get_sprint` | Progreso, tickets y burndown |
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | | | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | | | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | | | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
| Releases | `list_releases` | Releases de un proyecto | | Releases | `list_releases` | Releases de un proyecto |
| | `upcoming_releases` | Próximas releases de mis proyectos | | | `upcoming_releases` | Próximas releases de mis proyectos |
| | `get_release` | Progreso, notas, tickets y burnup | | | `get_release` | Progreso, notas, tickets y burnup |
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | | | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | | | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | | | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | | | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja |
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad, archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,

View File

@@ -14,7 +14,7 @@ cuando hay pistas, por dónde empezar.
| Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) | | Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) |
| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) | | Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) |
| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) | | Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) |
| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras | | Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas, panel para clientes) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras |
| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP | | Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP |
## 10.2 Fallos y deuda conocidos ## 10.2 Fallos y deuda conocidos
@@ -125,3 +125,10 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora, - Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora,
la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad, la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad,
qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`). qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`).
- Panel del proyecto, cuarta parte: actividad de 12 semanas en un mapa de calor, el recorrido de un ticket (dónde se
va el tiempo de creado a hecho, espera incluida, y la parte que es trabajo) y el embudo de propuestas del público
con las más votadas sin respuesta.
- Modo pantalla del panel: a pantalla completa para una tele del equipo, un bloque cada vez que cambia solo, en
directo y con la hora.
- Panel para clientes: un enlace de solo lectura, sin cuenta, con los bloques que elijan el propietario y los admins
(flujo, sprint, releases, épicas, actividad); se renueva o se retira desde Configuración.

Binary file not shown.

Binary file not shown.

View File

@@ -21,6 +21,8 @@ export * from './lib/transfer.service';
export * from './lib/automations.service'; export * from './lib/automations.service';
export * from './lib/calendar.service'; export * from './lib/calendar.service';
export * from './lib/calendar/calendar'; export * from './lib/calendar/calendar';
export * from './lib/sharing.service';
export * from './lib/sharing/dashboard-shares';
export * from './lib/automations/automations'; export * from './lib/automations/automations';
export * from './lib/automations/automation-rules'; export * from './lib/automations/automation-rules';
export * from './lib/transfer/import-export'; export * from './lib/transfer/import-export';

View File

@@ -13,6 +13,7 @@ import {
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
computeInsights, computeInsights,
HEATMAP_DAYS,
InsightsInput, InsightsInput,
simulateDays, simulateDays,
STALE_DAYS, STALE_DAYS,
@@ -392,4 +393,76 @@ describe('computeInsights', () => {
}, },
]); ]);
}); });
it('maps 12 weeks of activity, the whole way of done tickets and the proposals', () => {
const proposal = { labels: ['propuesta'] };
const issues = [
// Done: 2 days waiting in TODO, 3 at work, 1 in QA.
issue('A', 'DONE', C.DONE, { createdAt: '2026-10-01T10:00:00Z' }),
issue('P1', 'TODO', C.TODO, {
...proposal,
votes: 2,
} as Partial<IssueReadModel>),
issue('P2', 'TODO', C.TODO, {
...proposal,
votes: 5,
} as Partial<IssueReadModel>),
issue('P3', 'TODO', C.TODO, { ...proposal, ...inSprint }),
issue('P4', 'DONE', C.DONE, proposal),
issue('P5', 'CANCELLED', C.CANCELLED, proposal),
];
const timeline = [
step('A', '2026-10-01T10:00:00Z', 'TODO', C.TODO),
step('A', '2026-10-03T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('A', '2026-10-06T10:00:00Z', 'IN_QA', C.QA),
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
];
const insights = computeInsights({
issues,
timeline,
...BASE,
activity: [
{ day: '2026-10-15', count: 3 },
{ day: '2026-10-14', count: 1 },
{ day: '2026-01-01', count: 9 },
],
});
expect(insights.heatmap).toHaveLength(HEATMAP_DAYS);
// From the Monday 11 weeks back to this week's Sunday.
expect(insights.heatmap[0].day).toBe('2026-07-27');
expect(insights.heatmap.at(-1)?.day).toBe('2026-10-18');
expect(insights.heatmap.find((d) => d.day === '2026-10-15')?.count).toBe(3);
expect(insights.journey).toEqual({
samples: 1,
leadDays: 6,
flowEfficiency: 50,
steps: [
{ status: 'TODO', days: 2, share: 33 },
{ status: 'IN_PROGRESS', days: 3, share: 50 },
{ status: 'IN_QA', days: 1, share: 17 },
],
});
expect(insights.proposals).toMatchObject({
received: 5,
waiting: 2,
underWay: 1,
done: 1,
discarded: 1,
});
expect(insights.proposals?.topWaiting.map((p) => [p.key, p.votes])).toEqual(
[
['P2', 5],
['P1', 2],
],
);
});
it('has no proposals block while nobody has proposed anything', () => {
expect(
computeInsights({ issues: [], timeline: [], ...BASE }).proposals,
).toBeNull();
});
}); });

View File

@@ -1,5 +1,8 @@
import { import {
ActivityDayReadModel,
BacklogHealthReadModel, BacklogHealthReadModel,
JourneyReadModel,
ProposalsFunnelReadModel,
CustomFieldType, CustomFieldType,
FieldBreakdownReadModel, FieldBreakdownReadModel,
IssuePriority, IssuePriority,
@@ -28,6 +31,7 @@ import {
stepsByIssue, stepsByIssue,
weekOf, weekOf,
} from './analytics'; } from './analytics';
import { PROPOSAL_LABEL } from '../proposals';
/* /*
* The dashboard's reading of a project: what needs attention now (risks), * The dashboard's reading of a project: what needs attention now (risks),
@@ -61,6 +65,8 @@ export interface InsightsInput {
since: Date | null; since: Date | null;
/** Times the viewer was mentioned in the project since then. */ /** Times the viewer was mentioned in the project since then. */
mentions: number; mentions: number;
/** Activity events per day (`YYYY-MM-DD`), the last 12 weeks or more. */
activity?: { day: string; count: number }[];
now: Date; now: Date;
} }
@@ -72,6 +78,8 @@ const utcDay = (time: number) =>
); );
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`); const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
const round1 = (n: number) => Math.round(n * 10) / 10; const round1 = (n: number) => Math.round(n * 10) / 10;
/** Hundredths of a day (about a quarter of an hour): short stays still show. */
const round2 = (n: number) => Math.round(n * 100) / 100;
export function computeInsights( export function computeInsights(
input: InsightsInput, input: InsightsInput,
@@ -180,6 +188,127 @@ export function computeInsights(
backlog: backlogHealth(open, now), backlog: backlogHealth(open, now),
quality: quality(input.settings, live, steps, input.releases, now), quality: quality(input.settings, live, steps, input.releases, now),
fields: fieldBreakdown(input.settings, open), fields: fieldBreakdown(input.settings, open),
heatmap: heatmap(input.activity ?? [], now),
journey: journey(input.settings, live, steps, now),
proposals: proposalsFunnel(live, now),
};
}
/** Days shown by the heatmap: 12 weeks. */
export const HEATMAP_DAYS = 84;
/** The first day of the heatmap: the Monday 11 weeks before this week's. */
export function heatmapStart(now: number): string {
return new Date(Date.parse(weekOf(now)) - 11 * 7 * DAY)
.toISOString()
.slice(0, 10);
}
/** 12 weeks of activity, a day each, zero where nothing happened. */
function heatmap(
activity: { day: string; count: number }[],
now: number,
): ActivityDayReadModel[] {
const counts = new Map<string, number>();
for (const a of activity)
counts.set(a.day, (counts.get(a.day) ?? 0) + a.count);
const start = Date.parse(heatmapStart(now));
return Array.from({ length: HEATMAP_DAYS }, (_, n) => {
const day = new Date(start + n * DAY).toISOString().slice(0, 10);
return { day, count: counts.get(day) ?? 0 };
});
}
/**
* Where the time of a closed ticket goes, from created to done, status by
* status (waiting included), for the tickets done in the last 90 days.
*/
function journey(
settings: ProjectSettings,
issues: IssueReadModel[],
steps: Map<string, Step[]>,
now: number,
): JourneyReadModel {
const from = now - 90 * DAY;
const spent = new Map<string, number>();
let lead = 0;
let working = 0;
let samples = 0;
for (const issue of issues) {
if (issue.statusCategory !== StatusCategory.DONE) continue;
const list = steps.get(issue.id) ?? [];
// The last arrival at a done status, from somewhere else.
let end = -1;
list.forEach((step, i) => {
if (
i > 0 &&
step.category === StatusCategory.DONE &&
list[i - 1].category !== StatusCategory.DONE
)
end = i;
});
if (end < 0 || list[end].at < from) continue;
samples++;
lead += list[end].at - list[0].at;
for (let i = 0; i < end; i++) {
const stay = list[i + 1].at - list[i].at;
spent.set(list[i].status, (spent.get(list[i].status) ?? 0) + stay);
if (list[i].category === StatusCategory.IN_PROGRESS) working += stay;
}
}
const order = settings.statuses.map((s) => s.id);
const rank = (id: string) =>
order.includes(id) ? order.indexOf(id) : order.length;
return {
samples,
leadDays: samples ? round2(lead / samples / DAY) : null,
flowEfficiency: lead ? Math.round((working / lead) * 100) : null,
steps: [...spent.entries()]
.filter(([, time]) => time > 0)
.sort(([a], [b]) => rank(a) - rank(b))
.map(([status, time]) => ({
status,
days: round2(time / samples / DAY),
share: Math.round((time / lead) * 100),
})),
};
}
/** The public's proposals, from received to done or discarded. */
function proposalsFunnel(
issues: IssueReadModel[],
now: number,
): ProposalsFunnelReadModel | null {
const proposals = issues.filter((i) =>
(i.labels ?? []).includes(PROPOSAL_LABEL),
);
if (!proposals.length) return null;
const isWaiting = (i: IssueReadModel) =>
i.statusCategory === StatusCategory.TODO && !i.sprint && !i.release;
const open = proposals.filter((i) => !CLOSED.includes(i.statusCategory));
const waiting = open.filter(isWaiting);
const age = (i: IssueReadModel) =>
Math.floor((now - Date.parse(i.createdAt)) / DAY);
return {
received: proposals.length,
receivedLately: proposals.filter((i) => age(i) < 30).length,
waiting: waiting.length,
underWay: open.length - waiting.length,
done: proposals.filter((i) => i.statusCategory === StatusCategory.DONE)
.length,
discarded: proposals.filter(
(i) => i.statusCategory === StatusCategory.CANCELLED,
).length,
topWaiting: waiting
.sort((a, b) => (b.votes ?? 0) - (a.votes ?? 0) || age(b) - age(a))
.slice(0, 5)
.map((i) => ({
id: i.id,
key: i.key,
title: i.title,
votes: i.votes ?? 0,
days: age(i),
})),
}; };
} }

View File

@@ -1,4 +1,5 @@
import { GitIntegrationsRepository } from '../ports/git-integrations.repository'; import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain'; import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed'; import { ChangeFeed } from '../ports/change-feed';
@@ -8,7 +9,7 @@ import { versionOf } from './read-model-notifier';
/** /**
* Removes the project (and its activity and timeline) from the read model, * Removes the project (and its activity and timeline) from the read model,
* forgets its Git repositories' secrets, and tells its members, whose project * forgets its Git repositories' secrets and its link for clients, and tells its members, whose project
* lists drop it. Tickets, comments and * lists drop it. Tickets, comments and
* releases go as the cascade saga deletes them. * releases go as the cascade saga deletes them.
*/ */
@@ -21,6 +22,7 @@ export class ProjectDeletedEventHandler
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository, private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly changeFeed: ChangeFeed, private readonly changeFeed: ChangeFeed,
private readonly gitIntegrations: GitIntegrationsRepository, private readonly gitIntegrations: GitIntegrationsRepository,
private readonly shares: DashboardSharesRepository,
) {} ) {}
async handle(event: ProjectDeletedEvent) { async handle(event: ProjectDeletedEvent) {
@@ -28,6 +30,8 @@ export class ProjectDeletedEventHandler
await this.upsertMaterializedProjectRepository.remove(event.projectId); await this.upsertMaterializedProjectRepository.remove(event.projectId);
// No webhook of it should be accepted any more. // No webhook of it should be accepted any more.
await this.gitIntegrations.saveAll(event.projectId, []); await this.gitIntegrations.saveAll(event.projectId, []);
// Nor its link for clients.
await this.shares.remove(event.projectId);
if (!project) return; // Already removed (event handled twice). if (!project) return; // Already removed (event handled twice).
await this.changeFeed.publishProject( await this.changeFeed.publishProject(
{ {

View File

@@ -0,0 +1,15 @@
import { DashboardShareReadModel } from '@task-stream/api/tracker/domain';
/**
* One link for clients per project. The token is kept as it is (not hashed)
* so the link can be copied again; it only reads the blocks chosen.
*/
export abstract class DashboardSharesRepository {
abstract findByProject(
projectId: string,
): Promise<DashboardShareReadModel | null>;
abstract findByToken(token: string): Promise<DashboardShareReadModel | null>;
/** Replaces the project's link, if any. */
abstract save(share: DashboardShareReadModel): Promise<void>;
abstract remove(projectId: string): Promise<void>;
}

View File

@@ -30,3 +30,4 @@ export * from './rate-limit.store';
export * from './calendar-feeds.repository'; export * from './calendar-feeds.repository';
export * from './push-sender'; export * from './push-sender';
export * from './push-subscriptions.repository'; export * from './push-subscriptions.repository';
export * from './dashboard-shares.repository';

View File

@@ -5,7 +5,8 @@ import {
ProjectInsightsReadModel, ProjectInsightsReadModel,
SprintStatus, SprintStatus,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { computeInsights } from '../analytics/insights'; import { computeInsights, heatmapStart } from '../analytics/insights';
import { FindActivityRepository } from '../ports/find-activity.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository'; import { FindReleasesRepository } from '../ports/find-releases.repository';
@@ -27,13 +28,15 @@ export class GetProjectInsightsQueryHandler
private readonly findSprints: FindSprintsRepository, private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository, private readonly findReleases: FindReleasesRepository,
private readonly notifications: NotificationsRepository, private readonly notifications: NotificationsRepository,
private readonly findActivity: FindActivityRepository,
) {} ) {}
async execute( async execute(
query: GetProjectInsightsQuery, query: GetProjectInsightsQuery,
): Promise<ProjectInsightsReadModel> { ): Promise<ProjectInsightsReadModel> {
await this.access.assertSeesWholeProject(query.projectId, query.viewerId); await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
const [project, issues, timeline, sprints, releases, mentions] = const now = new Date();
const [project, issues, timeline, sprints, releases, mentions, activity] =
await Promise.all([ await Promise.all([
this.findProjects.findById(query.projectId), this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId), this.findIssues.findByProject(query.projectId),
@@ -41,6 +44,10 @@ export class GetProjectInsightsQueryHandler
this.findSprints.findByProject(query.projectId), this.findSprints.findByProject(query.projectId),
this.findReleases.findByProject(query.projectId), this.findReleases.findByProject(query.projectId),
this.mentionsSince(query), this.mentionsSince(query),
this.findActivity.dailyCounts(
[query.projectId],
heatmapStart(now.getTime()),
),
]); ]);
return computeInsights({ return computeInsights({
issues, issues,
@@ -53,7 +60,8 @@ export class GetProjectInsightsQueryHandler
members: project?.members ?? [], members: project?.members ?? [],
since: query.since, since: query.since,
mentions, mentions,
now: new Date(), activity,
now,
}); });
} }

View File

@@ -0,0 +1,55 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
DashboardShareReadModel,
SharedDashboardReadModel,
} from '@task-stream/api/tracker/domain';
import {
GetDashboardShareQuery,
GetSharedDashboardQuery,
ShareDashboardCommand,
UnshareDashboardCommand,
} from './sharing/dashboard-shares';
/** The project's dashboard for clients, behind a secret link. */
@Injectable()
export class SharingService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
/** The project's link, if it has one (owners and admins). */
share(
viewerId: string,
projectId: string,
): Promise<DashboardShareReadModel | null> {
return this.queryBus.execute(
new GetDashboardShareQuery(projectId, viewerId),
);
}
/** Turns the link on or changes its blocks; `renew` makes a new secret. */
save(
actorId: string,
projectId: string,
blocks: string[],
renew = false,
): Promise<DashboardShareReadModel> {
return this.commands.execute(
new ShareDashboardCommand(projectId, actorId, blocks, renew),
);
}
remove(actorId: string, projectId: string): Promise<void> {
return this.commands.execute(
new UnshareDashboardCommand(projectId, actorId),
);
}
/** What a link shows now, or null if no project has it. */
shared(token: string): Promise<SharedDashboardReadModel | null> {
return this.queryBus.execute(new GetSharedDashboardQuery(token));
}
}

View File

@@ -0,0 +1,255 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import {
DashboardShareReadModel,
DEFAULT_PROJECT_SETTINGS,
SHARED_BLOCKS,
SharedBlock,
SharedDashboardReadModel,
SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { computeInsights, heatmapStart } from '../analytics/insights';
import { WriteAccess } from '../commands/write-access';
import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
import { FindActivityRepository } from '../ports/find-activity.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
/** Epics a client sees, nearest due date first. */
const EPICS = 8;
/** Only known blocks, each once, in the order they are shown. */
function cleanBlocks(blocks: string[]): SharedBlock[] {
const chosen = new Set(blocks);
return SHARED_BLOCKS.filter((b) => chosen.has(b));
}
/**
* Turns the project's link for clients on (or changes its blocks). With
* `renew`, or the first time, a new secret: the old link stops working.
*/
export class ShareDashboardCommand extends Command<DashboardShareReadModel> {
constructor(
public readonly projectId: string,
public readonly actorId: string,
public readonly blocks: string[],
public readonly renew: boolean,
) {
super();
}
}
/** The project's link for clients stops working. */
export class UnshareDashboardCommand extends Command<void> {
constructor(
public readonly projectId: string,
public readonly actorId: string,
) {
super();
}
}
/** The project's link for clients, for who manages it. */
export class GetDashboardShareQuery extends Query<DashboardShareReadModel | null> {
constructor(
public readonly projectId: string,
public readonly viewerId: string,
) {
super();
}
}
/** What a link shows, read now; null for an unknown token. */
export class GetSharedDashboardQuery extends Query<SharedDashboardReadModel | null> {
constructor(public readonly token: string) {
super();
}
}
@CommandHandler(ShareDashboardCommand)
export class ShareDashboardCommandHandler
implements ICommandHandler<ShareDashboardCommand>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly access: WriteAccess,
) {}
async execute(
command: ShareDashboardCommand,
): Promise<DashboardShareReadModel> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const blocks = cleanBlocks(command.blocks);
assertThat(blocks.length > 0, 'Pick at least one block');
const current = await this.shares.findByProject(command.projectId);
const fresh = !current || command.renew;
const share: DashboardShareReadModel = {
projectId: command.projectId,
// 32 random bytes: nobody guesses it.
token: fresh ? randomBytes(32).toString('base64url') : current.token,
blocks,
createdAt: fresh ? new Date().toISOString() : current.createdAt,
};
await this.shares.save(share);
return share;
}
}
@CommandHandler(UnshareDashboardCommand)
export class UnshareDashboardCommandHandler
implements ICommandHandler<UnshareDashboardCommand>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly access: WriteAccess,
) {}
async execute(command: UnshareDashboardCommand): Promise<void> {
(await this.access.project(command.projectId)).assertManages(
command.actorId,
);
await this.shares.remove(command.projectId);
}
}
@QueryHandler(GetDashboardShareQuery)
export class GetDashboardShareQueryHandler
implements
IQueryHandler<GetDashboardShareQuery, DashboardShareReadModel | null>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly access: WriteAccess,
) {}
async execute(
query: GetDashboardShareQuery,
): Promise<DashboardShareReadModel | null> {
(await this.access.project(query.projectId)).assertManages(query.viewerId);
return this.shares.findByProject(query.projectId);
}
}
/**
* The chosen blocks of a project, for anyone with the link: computed like
* the dashboard, then cut down to what outsiders may see.
*/
@QueryHandler(GetSharedDashboardQuery)
export class GetSharedDashboardQueryHandler
implements
IQueryHandler<GetSharedDashboardQuery, SharedDashboardReadModel | null>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findActivity: FindActivityRepository,
) {}
async execute(
query: GetSharedDashboardQuery,
): Promise<SharedDashboardReadModel | null> {
const share = await this.shares.findByToken(query.token);
if (!share) return null;
const project = await this.findProjects.findById(share.projectId);
if (!project) return null;
const has = (block: SharedBlock) => share.blocks.includes(block);
const now = new Date();
const [issues, timeline, sprints, releases, epics, activity] =
await Promise.all([
this.findIssues.findByProject(project.id),
this.findTimeline.findByProject(project.id),
this.findSprints.findByProject(project.id),
this.findReleases.findByProject(project.id),
has('epics') ? this.findIssues.epics(project.id) : [],
has('activity')
? this.findActivity.dailyCounts(
[project.id],
heatmapStart(now.getTime()),
)
: [],
]);
const settings = project.settings ?? DEFAULT_PROJECT_SETTINGS;
const reading = computeInsights({
issues,
timeline,
settings,
activeSprint:
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
sprints,
releases,
members: project.members,
since: null,
mentions: 0,
activity,
now,
});
const live = issues.filter((i) => !i.trashedAt);
return {
projectKey: project.key,
projectName: project.name,
generatedAt: now.toISOString(),
blocks: share.blocks,
flow: has('flow')
? settings.statuses
.filter((s) => !s.archived)
.map((s) => ({
status: s.id,
name: s.name,
color: s.color,
count: live.filter((i) => i.status === s.id).length,
}))
: null,
sprint: has('sprint') ? reading.sprint : null,
releases: has('releases') ? reading.releases : null,
epics: has('epics')
? epics
.filter(
(e) =>
!FINISHED.includes(e.issue.statusCategory) &&
!e.issue.trashedAt,
)
.sort((a, b) =>
(a.issue.dueDate ?? '9999').localeCompare(
b.issue.dueDate ?? '9999',
),
)
.slice(0, EPICS)
.map((e) => ({
key: e.issue.key,
title: e.issue.title,
dueDate: e.issue.dueDate,
done: e.progress.finished,
total: e.progress.total,
percent: e.progress.percent,
}))
: null,
activity: has('activity') ? reading.heatmap : null,
};
}
}
export const SHARING_HANDLERS = [
ShareDashboardCommandHandler,
UnshareDashboardCommandHandler,
GetDashboardShareQueryHandler,
GetSharedDashboardQueryHandler,
];

View File

@@ -49,6 +49,8 @@ import { PresenceService } from './presence.service';
import { AUTOMATION_HANDLERS } from './automations/automations'; import { AUTOMATION_HANDLERS } from './automations/automations';
import { CALENDAR_HANDLERS } from './calendar/calendar'; import { CALENDAR_HANDLERS } from './calendar/calendar';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
import { SharingService } from './sharing.service';
import { SHARING_HANDLERS } from './sharing/dashboard-shares';
import { AutomationsEventHandler } from './automations/automations.event-handler'; import { AutomationsEventHandler } from './automations/automations.event-handler';
/** Notifications: derived from events, kept and dismissed per person. */ /** Notifications: derived from events, kept and dismissed per person. */
@@ -66,6 +68,7 @@ const NOTIFICATIONS = [
const SERVICES = [ const SERVICES = [
CalendarService, CalendarService,
SharingService,
ProjectsService, ProjectsService,
IssuesService, IssuesService,
CommentsService, CommentsService,
@@ -110,6 +113,7 @@ const SERVICES = [
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,
...AUTOMATION_HANDLERS, ...AUTOMATION_HANDLERS,
...CALENDAR_HANDLERS, ...CALENDAR_HANDLERS,
...SHARING_HANDLERS,
AutomationsEventHandler, AutomationsEventHandler,
], ],
exports: SERVICES, exports: SERVICES,

View File

@@ -11,3 +11,4 @@ export * from './saved-filter.read-model';
export * from './sprint.read-model'; export * from './sprint.read-model';
export * from './insights.read-model'; export * from './insights.read-model';
export * from './home.read-model'; export * from './home.read-model';
export * from './shared-dashboard.read-model';

View File

@@ -171,6 +171,60 @@ export class FieldBreakdownReadModel {
none!: number; none!: number;
} }
/** How much happened on one day (every event of the activity feed). */
export class ActivityDayReadModel {
/** `YYYY-MM-DD`. */
day!: string;
count!: number;
}
/** Where a closed ticket's time goes, from created to done. */
export class JourneyStepReadModel {
/** Status id of the project's settings. */
status!: string;
/** Average days a closed ticket spent in it. */
days!: number;
/** Its part of the whole lead time, 0–100. */
share!: number;
}
/** The whole way of the tickets closed lately (90 days). */
export class JourneyReadModel {
/** Tickets measured: closed as done in the last 90 days. */
samples!: number;
/** Average days from created to done. */
leadDays!: number | null;
/** Part of that time in an in-progress status (work, not waiting), 0–100. */
flowEfficiency!: number | null;
/** In board order, only the statuses they went through. */
steps!: JourneyStepReadModel[];
}
/** A proposal waiting for an answer, with its votes. */
export class WaitingProposalReadModel {
id!: string;
key!: string;
title!: string;
votes!: number;
/** Days since it came in. */
days!: number;
}
/** The public's proposals (tickets labelled «propuesta»), from in to out. */
export class ProposalsFunnelReadModel {
received!: number;
/** In the last 30 days. */
receivedLately!: number;
/** Open, not started, in no sprint nor release. */
waiting!: number;
/** Open and taken up (started, or planned in a sprint or release). */
underWay!: number;
done!: number;
discarded!: number;
/** The most voted of those waiting, at most five. */
topWaiting!: WaitingProposalReadModel[];
}
/** What the project dashboard reads beyond the counts. */ /** What the project dashboard reads beyond the counts. */
export class ProjectInsightsReadModel { export class ProjectInsightsReadModel {
generatedAt!: string; generatedAt!: string;
@@ -189,6 +243,11 @@ export class ProjectInsightsReadModel {
quality!: QualityReadModel; quality!: QualityReadModel;
/** One per active list (SELECT) custom field. */ /** One per active list (SELECT) custom field. */
fields!: FieldBreakdownReadModel[]; fields!: FieldBreakdownReadModel[];
/** The last 12 weeks, oldest first, starting on a Monday (84 days). */
heatmap!: ActivityDayReadModel[];
journey!: JourneyReadModel;
/** Null while the project has never had a proposal. */
proposals!: ProposalsFunnelReadModel | null;
} }
/** One of my projects at a glance, for the portfolio. */ /** One of my projects at a glance, for the portfolio. */

View File

@@ -0,0 +1,59 @@
import {
ActivityDayReadModel,
ReleaseForecastReadModel,
SprintPulseReadModel,
} from './insights.read-model';
/** The blocks a project can show to outsiders, in the order they are shown. */
export const SHARED_BLOCKS = [
'flow',
'sprint',
'releases',
'epics',
'activity',
] as const;
export type SharedBlock = (typeof SHARED_BLOCKS)[number];
/** A project's link for clients: the secret, and what it shows. */
export class DashboardShareReadModel {
projectId!: string;
/** Random and long; whoever has it sees the chosen blocks. */
token!: string;
blocks!: SharedBlock[];
createdAt!: string;
}
/** Tickets in one status (the board's columns, archived ones left out). */
export class SharedFlowReadModel {
status!: string;
name!: string;
/** A colour name of the project's settings. */
color!: string;
count!: number;
}
/** An open epic and how far it got: no more than its key and title. */
export class SharedEpicReadModel {
key!: string;
title!: string;
dueDate!: string | null;
done!: number;
total!: number;
percent!: number;
}
/**
* What a client sees through the link: only the chosen blocks (the others
* are null), nothing about people or single tickets but the epics.
*/
export class SharedDashboardReadModel {
projectKey!: string;
projectName!: string;
generatedAt!: string;
blocks!: SharedBlock[];
flow!: SharedFlowReadModel[] | null;
sprint!: SprintPulseReadModel | null;
releases!: ReleaseForecastReadModel[] | null;
epics!: SharedEpicReadModel[] | null;
activity!: ActivityDayReadModel[] | null;
}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { DashboardSharesRepository } from '@task-stream/api/tracker/application';
import {
DashboardShareReadModel,
SharedBlock,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { DashboardShareDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoDashboardSharesRepository extends DashboardSharesRepository {
constructor(
@InjectModel(ReadModels.DashboardShare)
private readonly shares: Model<DashboardShareDoc>,
) {
super();
}
async findByProject(
projectId: string,
): Promise<DashboardShareReadModel | null> {
const doc = await this.shares
.findOne({ id: projectId })
.lean<DashboardShareDoc>();
return doc ? toShare(doc) : null;
}
async findByToken(token: string): Promise<DashboardShareReadModel | null> {
const doc = await this.shares.findOne({ token }).lean<DashboardShareDoc>();
return doc ? toShare(doc) : null;
}
async save(share: DashboardShareReadModel): Promise<void> {
await this.shares.updateOne(
{ id: share.projectId },
{
$set: {
token: share.token,
blocks: share.blocks,
createdAt: share.createdAt,
},
},
{ upsert: true },
);
}
async remove(projectId: string): Promise<void> {
await this.shares.deleteOne({ id: projectId });
}
}
function toShare(doc: DashboardShareDoc): DashboardShareReadModel {
return {
projectId: doc.id,
token: doc.token,
blocks: (doc.blocks ?? []) as SharedBlock[],
createdAt: doc.createdAt,
};
}

View File

@@ -74,6 +74,13 @@ export interface PushSubscriptionDoc {
keys: { p256dh: string; auth: string }; keys: { p256dh: string; auth: string };
createdAt: string; createdAt: string;
} }
export interface DashboardShareDoc {
/** The project's id. */
id: string;
token: string;
blocks: string[];
createdAt: string;
}
export interface CalendarFeedDoc { export interface CalendarFeedDoc {
/** The user's id. */ /** The user's id. */
id: string; id: string;
@@ -103,6 +110,7 @@ export const ReadModels = {
Automation: 'Automation', Automation: 'Automation',
AutomationRun: 'AutomationRun', AutomationRun: 'AutomationRun',
CalendarFeed: 'CalendarFeed', CalendarFeed: 'CalendarFeed',
DashboardShare: 'DashboardShare',
PushSubscription: 'PushSubscription', PushSubscription: 'PushSubscription',
} as const; } as const;
@@ -370,6 +378,19 @@ class CalendarFeedDocument {
export const CalendarFeedSchema = export const CalendarFeedSchema =
SchemaFactory.createForClass(CalendarFeedDocument); SchemaFactory.createForClass(CalendarFeedDocument);
/** One per project: the secret of its link for clients, and what it shows. */
@Schema({ ...LOOSE, collection: 'dashboard_shares' })
class DashboardShareDocument {
/** The project's id. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, required: true, unique: true }) token!: string;
@Prop({ type: [String] }) blocks!: string[];
@Prop({ type: String }) createdAt!: string;
}
export const DashboardShareSchema = SchemaFactory.createForClass(
DashboardShareDocument,
);
/** The devices where each person turned push on. */ /** The devices where each person turned push on. */
@Schema({ ...LOOSE, collection: 'push_subscriptions' }) @Schema({ ...LOOSE, collection: 'push_subscriptions' })
class PushSubscriptionDocument { class PushSubscriptionDocument {
@@ -404,6 +425,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.MyView, schema: MyViewSchema }, { name: ReadModels.MyView, schema: MyViewSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
]; ];

View File

@@ -8,6 +8,7 @@ import {
NotificationMailer, NotificationMailer,
NotificationsRepository, NotificationsRepository,
CalendarFeedsRepository, CalendarFeedsRepository,
DashboardSharesRepository,
PushSubscriptionsRepository, PushSubscriptionsRepository,
PushSender, PushSender,
SavedFiltersRepository, SavedFiltersRepository,
@@ -72,6 +73,7 @@ import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
import { RedisPresenceStore } from './redis/redis-presence.store'; import { RedisPresenceStore } from './redis/redis-presence.store';
import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository'; import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
import { MongoDashboardSharesRepository } from './mongo/dashboard-shares.repository';
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository'; import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
import { WebPushSender } from './push/web-push.sender'; import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
@@ -141,6 +143,10 @@ const PORTS = [
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional) // Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository }, { provide: NotificationsRepository, useClass: MongoNotificationsRepository },
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository }, { provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
{
provide: DashboardSharesRepository,
useClass: MongoDashboardSharesRepository,
},
{ {
provide: PushSubscriptionsRepository, provide: PushSubscriptionsRepository,
useClass: MongoPushSubscriptionsRepository, useClass: MongoPushSubscriptionsRepository,

View File

@@ -861,3 +861,22 @@ export class SaveMyViewInput {
@Type(() => MyViewCriteriaInput) @Type(() => MyViewCriteriaInput)
criteria!: MyViewCriteriaInput; criteria!: MyViewCriteriaInput;
} }
@InputType({ description: "The project's link for clients" })
export class ShareDashboardInput {
@Field(() => ID) @IsUUID() projectId!: string;
@Field(() => [String], {
description: 'Blocks to show: flow, sprint, releases, epics, activity',
})
@IsArray()
@ArrayMaxSize(10)
@IsString({ each: true })
blocks!: string[];
@Field({
nullable: true,
description: 'A new secret: the old link stops working',
})
@IsOptional()
@IsBoolean()
renew?: boolean;
}

View File

@@ -0,0 +1,60 @@
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import {
CurrentUser,
OptionalAuth,
} from '@task-stream/api/identity/presenters';
import { SharingService } from '@task-stream/api/tracker/application';
import { ShareDashboardInput } from './inputs';
import { DashboardShareGql, SharedDashboardGql } from './types';
/** The project's dashboard for clients, behind a secret link. */
@Resolver(() => DashboardShareGql)
export class SharingResolver {
constructor(private readonly sharing: SharingService) {}
@Query(() => DashboardShareGql, {
nullable: true,
description: "The project's link for clients, if any (owners and admins)",
})
dashboardShare(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.sharing.share(me.id, projectId);
}
@Mutation(() => DashboardShareGql, {
description:
'Turn the link on or change its blocks; renew for a new secret',
})
shareDashboard(
@CurrentUser() me: User,
@Args('input') input: ShareDashboardInput,
) {
return this.sharing.save(
me.id,
input.projectId,
input.blocks,
input.renew ?? false,
);
}
@Mutation(() => Boolean, { description: 'The link stops working' })
async unshareDashboard(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
await this.sharing.remove(me.id, projectId);
return true;
}
@OptionalAuth()
@Query(() => SharedDashboardGql, {
nullable: true,
description: 'What a link for clients shows, for anyone with it',
})
sharedDashboard(@Args('token') token: string) {
return this.sharing.shared(token);
}
}

View File

@@ -1038,6 +1038,67 @@ export class FieldBreakdownGql {
none!: number; none!: number;
} }
@ObjectType('ActivityDay', { description: 'How much happened on one day' })
export class ActivityDayGql {
@Field({ description: 'YYYY-MM-DD' }) day!: string;
@Field(() => Int) count!: number;
}
@ObjectType('JourneyStep')
export class JourneyStepGql {
@Field({ description: 'Status id of the project settings' }) status!: string;
@Field(() => Float, {
description: 'Average days a closed ticket spent in it',
})
days!: number;
@Field(() => Int, { description: 'Its part of the whole lead time, 0-100' })
share!: number;
}
@ObjectType('Journey', {
description:
'Where the time of the tickets done in the last 90 days went, created to done',
})
export class JourneyGql {
@Field(() => Int) samples!: number;
@Field(() => Float, { nullable: true }) leadDays!: number | null;
@Field(() => Int, {
nullable: true,
description: 'Part of the lead time in an in-progress status, 0-100',
})
flowEfficiency!: number | null;
@Field(() => [JourneyStepGql]) steps!: JourneyStepGql[];
}
@ObjectType('WaitingProposal')
export class WaitingProposalGql {
@Field(() => ID) id!: string;
@Field() key!: string;
@Field() title!: string;
@Field(() => Int) votes!: number;
@Field(() => Int, { description: 'Days since it came in' }) days!: number;
}
@ObjectType('ProposalsFunnel', {
description: "The public's proposals, from received to done or discarded",
})
export class ProposalsFunnelGql {
@Field(() => Int) received!: number;
@Field(() => Int, { description: 'In the last 30 days' })
receivedLately!: number;
@Field(() => Int, {
description: 'Open, not started, in no sprint nor release',
})
waiting!: number;
@Field(() => Int) underWay!: number;
@Field(() => Int) done!: number;
@Field(() => Int) discarded!: number;
@Field(() => [WaitingProposalGql], {
description: 'Most voted waiting, at most 5',
})
topWaiting!: WaitingProposalGql[];
}
@ObjectType('ProjectInsights', { @ObjectType('ProjectInsights', {
description: description:
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes', 'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
@@ -1062,6 +1123,16 @@ export class ProjectInsightsGql {
description: 'One per active list custom field', description: 'One per active list custom field',
}) })
fields!: FieldBreakdownGql[]; fields!: FieldBreakdownGql[];
@Field(() => [ActivityDayGql], {
description: 'The last 12 weeks from a Monday, oldest first (84 days)',
})
heatmap!: ActivityDayGql[];
@Field(() => JourneyGql) journey!: JourneyGql;
@Field(() => ProposalsFunnelGql, {
nullable: true,
description: 'Null while the project has never had a proposal',
})
proposals!: ProposalsFunnelGql | null;
} }
@ObjectType('Presence', { @ObjectType('Presence', {
@@ -1189,3 +1260,53 @@ export class HomeGql {
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql; @Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
@Field(() => [OnlineGql]) online!: OnlineGql[]; @Field(() => [OnlineGql]) online!: OnlineGql[];
} }
@ObjectType('DashboardShare', {
description: "The project's link for clients (owners and admins)",
})
export class DashboardShareGql {
@Field(() => ID) projectId!: string;
@Field({ description: 'Secret of the link /share/{token}' }) token!: string;
@Field(() => [String]) blocks!: string[];
@Field() createdAt!: string;
}
@ObjectType('SharedFlow', { description: 'Tickets in one status' })
export class SharedFlowGql {
@Field() status!: string;
@Field() name!: string;
@Field({ description: 'A colour name of the project settings' })
color!: string;
@Field(() => Int) count!: number;
}
@ObjectType('SharedEpic', { description: 'An open epic and how far it got' })
export class SharedEpicGql {
@Field() key!: string;
@Field() title!: string;
@Field(() => String, { nullable: true }) dueDate!: string | null;
@Field(() => Int) done!: number;
@Field(() => Int) total!: number;
@Field(() => Int) percent!: number;
}
@ObjectType('SharedDashboard', {
description:
'What a link for clients shows: only the chosen blocks (the others are null)',
})
export class SharedDashboardGql {
@Field() projectKey!: string;
@Field() projectName!: string;
@Field() generatedAt!: string;
@Field(() => [String]) blocks!: string[];
@Field(() => [SharedFlowGql], { nullable: true })
flow!: SharedFlowGql[] | null;
@Field(() => SprintPulseGql, { nullable: true })
sprint!: SprintPulseGql | null;
@Field(() => [ReleaseForecastGql], { nullable: true })
releases!: ReleaseForecastGql[] | null;
@Field(() => [SharedEpicGql], { nullable: true })
epics!: SharedEpicGql[] | null;
@Field(() => [ActivityDayGql], { nullable: true })
activity!: ActivityDayGql[] | null;
}

View File

@@ -1526,3 +1526,22 @@ export class AddGitRepositoryDto {
@MaxLength(40) @MaxLength(40)
name!: string; name!: string;
} }
export class ShareDashboardDto {
@ApiProperty({
type: [String],
example: ['flow', 'sprint', 'releases'],
description: 'Blocks to show: flow, sprint, releases, epics, activity',
})
@IsArray()
@ArrayMaxSize(10)
@IsString({ each: true })
blocks!: string[];
@ApiPropertyOptional({
description: 'A new secret: the old link stops working',
})
@IsOptional()
@IsBoolean()
renew?: boolean;
}

View File

@@ -1117,6 +1117,53 @@ export class FieldBreakdownDto {
@ApiProperty({ description: 'Open tickets with no value' }) none!: number; @ApiProperty({ description: 'Open tickets with no value' }) none!: number;
} }
export class ActivityDayDto {
@ApiProperty({ example: '2026-10-15' }) day!: string;
@ApiProperty() count!: number;
}
export class JourneyStepDto {
@ApiProperty({ description: 'Status id of the project settings' })
status!: string;
@ApiProperty({ description: 'Average days a closed ticket spent in it' })
days!: number;
@ApiProperty({ description: 'Its part of the whole lead time, 0-100' })
share!: number;
}
export class JourneyDto {
@ApiProperty({ description: 'Tickets done in the last 90 days' })
samples!: number;
@ApiProperty({ type: Number, nullable: true }) leadDays!: number | null;
@ApiProperty({
type: Number,
nullable: true,
description: 'Part of the lead time in an in-progress status, 0-100',
})
flowEfficiency!: number | null;
@ApiProperty({ type: [JourneyStepDto] }) steps!: JourneyStepDto[];
}
export class WaitingProposalDto {
@ApiProperty() id!: string;
@ApiProperty() key!: string;
@ApiProperty() title!: string;
@ApiProperty() votes!: number;
@ApiProperty({ description: 'Days since it came in' }) days!: number;
}
export class ProposalsFunnelDto {
@ApiProperty() received!: number;
@ApiProperty({ description: 'In the last 30 days' }) receivedLately!: number;
@ApiProperty({ description: 'Open, not started, in no sprint nor release' })
waiting!: number;
@ApiProperty() underWay!: number;
@ApiProperty() done!: number;
@ApiProperty() discarded!: number;
@ApiProperty({ type: [WaitingProposalDto] })
topWaiting!: WaitingProposalDto[];
}
export class ProjectInsightsDto { export class ProjectInsightsDto {
@ApiProperty() generatedAt!: string; @ApiProperty() generatedAt!: string;
@ApiProperty({ type: SprintPulseDto, nullable: true }) @ApiProperty({ type: SprintPulseDto, nullable: true })
@@ -1139,6 +1186,18 @@ export class ProjectInsightsDto {
description: 'One per active list custom field', description: 'One per active list custom field',
}) })
fields!: FieldBreakdownDto[]; fields!: FieldBreakdownDto[];
@ApiProperty({
type: [ActivityDayDto],
description: 'The last 12 weeks from a Monday, oldest first (84 days)',
})
heatmap!: ActivityDayDto[];
@ApiProperty({ type: JourneyDto }) journey!: JourneyDto;
@ApiProperty({
type: ProposalsFunnelDto,
nullable: true,
description: 'Null while the project has never had a proposal',
})
proposals!: ProposalsFunnelDto | null;
} }
export class PortfolioProjectDto { export class PortfolioProjectDto {
@@ -1246,3 +1305,46 @@ export class HomeDto {
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto; @ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
@ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[]; @ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[];
} }
export class DashboardShareDto {
@ApiProperty() projectId!: string;
@ApiProperty({ description: 'Secret of the link /share/{token}' })
token!: string;
@ApiProperty({ type: [String], example: ['flow', 'sprint', 'releases'] })
blocks!: string[];
@ApiProperty() createdAt!: string;
}
export class SharedFlowDto {
@ApiProperty() status!: string;
@ApiProperty() name!: string;
@ApiProperty({ description: 'A colour name of the project settings' })
color!: string;
@ApiProperty() count!: number;
}
export class SharedEpicDto {
@ApiProperty() key!: string;
@ApiProperty() title!: string;
@ApiProperty({ type: String, nullable: true }) dueDate!: string | null;
@ApiProperty() done!: number;
@ApiProperty() total!: number;
@ApiProperty() percent!: number;
}
export class SharedDashboardDto {
@ApiProperty() projectKey!: string;
@ApiProperty() projectName!: string;
@ApiProperty() generatedAt!: string;
@ApiProperty({ type: [String] }) blocks!: string[];
@ApiProperty({ type: [SharedFlowDto], nullable: true })
flow!: SharedFlowDto[] | null;
@ApiProperty({ type: SprintPulseDto, nullable: true })
sprint!: SprintPulseDto | null;
@ApiProperty({ type: [ReleaseForecastDto], nullable: true })
releases!: ReleaseForecastDto[] | null;
@ApiProperty({ type: [SharedEpicDto], nullable: true })
epics!: SharedEpicDto[] | null;
@ApiProperty({ type: [ActivityDayDto], nullable: true })
activity!: ActivityDayDto[] | null;
}

View File

@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Put,
Res,
} from '@nestjs/common';
import {
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { SharingService } from '@task-stream/api/tracker/application';
import type { Response } from 'express';
import { ShareDashboardDto } from './dto/requests.dto';
import { DashboardShareDto, SharedDashboardDto } from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
/**
* The project's dashboard for clients: a secret link (/share/{token}) that
* shows the blocks its owners and admins chose, without an account.
*/
@ApiTags('Projects')
@Controller()
export class SharingController {
constructor(
private readonly sharing: SharingService,
private readonly refs: Refs,
) {}
@Get('projects/:project/share')
@ApiPireonAuth()
@PROJECT
@ApiOperation({ summary: "The project's link for clients, if any" })
@ApiOkResponse({ type: DashboardShareDto })
@ApiNoContentResponse({ description: 'It has none' })
@ApiReadErrors()
async share(
@CurrentUser() me: User,
@Param('project') project: string,
@Res({ passthrough: true }) response: Response,
) {
const share = await this.sharing.share(
me.id,
await this.refs.projectId(me.id, project),
);
if (!share) response.status(204);
return share ?? undefined;
}
@Put('projects/:project/share')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Turn the link on or change its blocks (renew: a new secret)',
})
@ApiOkResponse({ type: DashboardShareDto })
@ApiCommandErrors()
async save(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: ShareDashboardDto,
) {
return this.sharing.save(
me.id,
await this.refs.projectId(me.id, project),
body.blocks,
body.renew ?? false,
);
}
@Delete('projects/:project/share')
@HttpCode(204)
@ApiPireonAuth()
@PROJECT
@ApiOperation({ summary: 'The link stops working' })
@ApiNoContentResponse()
@ApiCommandErrors()
async remove(@CurrentUser() me: User, @Param('project') project: string) {
await this.sharing.remove(me.id, await this.refs.projectId(me.id, project));
}
@Get('shared/dashboards/:token')
@Public()
@ApiOperation({
summary: 'What a link for clients shows (no session)',
description:
'Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.',
})
@ApiOkResponse({ type: SharedDashboardDto })
async shared(@Param('token') token: string) {
const dashboard = /^[\w-]{20,100}$/.test(token)
? await this.sharing.shared(token)
: null;
if (!dashboard) throw new NotFoundException('Unknown link');
return dashboard;
}
}

View File

@@ -28,7 +28,9 @@ import { SprintsController } from './http/sprints.controller';
import { GitController } from './http/git.controller'; import { GitController } from './http/git.controller';
import { GitResolver } from './graphql/git.resolver'; import { GitResolver } from './graphql/git.resolver';
import { WebhookResolver } from './graphql/webhook.resolver'; import { WebhookResolver } from './graphql/webhook.resolver';
import { SharingResolver } from './graphql/sharing.resolver';
import { WebhooksController } from './http/webhooks.controller'; import { WebhooksController } from './http/webhooks.controller';
import { SharingController } from './http/sharing.controller';
import { TransferController } from './http/transfer.controller'; import { TransferController } from './http/transfer.controller';
import { AutomationResolver } from './graphql/automation.resolver'; import { AutomationResolver } from './graphql/automation.resolver';
@@ -65,6 +67,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
SprintsController, SprintsController,
GitController, GitController,
WebhooksController, WebhooksController,
SharingController,
TransferController, TransferController,
], ],
providers: [ providers: [
@@ -84,6 +87,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
SprintResolver, SprintResolver,
GitResolver, GitResolver,
WebhookResolver, WebhookResolver,
SharingResolver,
AutomationResolver, AutomationResolver,
], ],
}) })

View File

@@ -1,6 +1,7 @@
export * from './lib/notifications'; export * from './lib/notifications';
export * from './lib/facades/projects.facade'; export * from './lib/facades/projects.facade';
export * from './lib/facades/calendar.facade'; export * from './lib/facades/calendar.facade';
export * from './lib/facades/sharing.facade';
export * from './lib/facades/push.facade'; export * from './lib/facades/push.facade';
export * from './lib/facades/project.facade'; export * from './lib/facades/project.facade';
export * from './lib/facades/board.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/calendar';
export * from './lib/models/insights'; export * from './lib/models/insights';
export * from './lib/models/home'; export * from './lib/models/home';
export * from './lib/models/sharing';

View File

@@ -126,6 +126,33 @@ export interface FieldBreakdown {
none: number; none: number;
} }
/** Where a closed ticket's time goes, status by status. */
export interface Journey {
/** Tickets done in the last 90 days. */
samples: number;
leadDays: number | null;
/** Part of the lead time at work (an in-progress status), 0–100. */
flowEfficiency: number | null;
steps: { status: string; days: number; share: number }[];
}
/** The public's proposals, from received to done or discarded. */
export interface ProposalsFunnel {
received: number;
receivedLately: number;
waiting: number;
underWay: number;
done: number;
discarded: number;
topWaiting: {
id: string;
key: string;
title: string;
votes: number;
days: number;
}[];
}
/** The dashboard's reading of a project beyond the counts. */ /** The dashboard's reading of a project beyond the counts. */
export interface ProjectInsights { export interface ProjectInsights {
generatedAt: string; generatedAt: string;
@@ -140,6 +167,11 @@ export interface ProjectInsights {
backlog: BacklogHealth; backlog: BacklogHealth;
quality: Quality; quality: Quality;
fields: FieldBreakdown[]; fields: FieldBreakdown[];
/** 12 weeks from a Monday, a day each (84). */
heatmap: { day: string; count: number }[];
journey: Journey;
/** Null while nobody has proposed anything. */
proposals: ProposalsFunnel | null;
} }
/** One of my active projects at a glance. */ /** One of my active projects at a glance. */

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 { Home } from '../models/home';
import { PortfolioProject, ProjectInsights } from '../models/insights'; import { PortfolioProject, ProjectInsights } from '../models/insights';
import {
DashboardShare,
SharedBlock,
SharedDashboard,
} from '../models/sharing';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
Activity, Activity,
@@ -368,6 +373,21 @@ export abstract class CalendarGateway {
abstract revoke(): Observable<void>; 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 { export abstract class NotificationsGateway {
abstract mine( abstract mine(
unreadOnly: boolean, 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; 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 */ /** PLANNED, ACTIVE (one per project at a time) or CLOSED */
export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED'; export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
@@ -2059,6 +2068,28 @@ export type ProjectInsightsQuery = {
none: number; none: number;
counts: Array<{ value: string; count: number }>; counts: Array<{ value: string; count: number }>;
}>; }>;
heatmap: Array<{ day: string; count: number }>;
journey: {
samples: number;
leadDays: number | null;
flowEfficiency: number | null;
steps: Array<{ status: string; days: number; share: number }>;
};
proposals: {
received: number;
receivedLately: number;
waiting: number;
underWay: number;
done: number;
discarded: number;
topWaiting: Array<{
id: string;
key: string;
title: string;
votes: number;
days: number;
}>;
} | null;
}; };
}; };
}; };
@@ -2415,6 +2446,98 @@ export type DeleteMyViewMutationVariables = Exact<{
export type DeleteMyViewMutation = { deleteMyView: boolean }; 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 = { export type SprintFieldsFragment = {
id: string; id: string;
projectId: string; projectId: string;
@@ -3926,6 +4049,28 @@ export const SavedFilterFieldsFragmentDoc = {
}, },
], ],
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>; } 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 = { export const SprintFieldsFragmentDoc = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -12397,6 +12542,127 @@ export const ProjectInsightsDocument = {
], ],
}, },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'heatmap' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'day' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'count' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'journey' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'samples' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'leadDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'flowEfficiency' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'steps' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'status' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'days' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'share' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'proposals' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'received' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'receivedLately' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'waiting' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'underWay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'done' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'discarded' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'topWaiting' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'id' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'key' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'title' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'votes' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'days' },
},
],
},
},
],
},
},
], ],
}, },
}, },
@@ -14234,6 +14500,391 @@ export const DeleteMyViewDocument = {
DeleteMyViewMutation, DeleteMyViewMutation,
DeleteMyViewMutationVariables 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 = { export const SprintsDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [

View File

@@ -296,6 +296,35 @@ query ProjectInsights($idOrKey: String!, $since: String) {
} }
none none
} }
heatmap {
day
count
}
journey {
samples
leadDays
flowEfficiency
steps {
status
days
share
}
}
proposals {
received
receivedLately
waiting
underWay
done
discarded
topWaiting {
id
key
title
votes
days
}
}
} }
} }
} }

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, WebhooksGateway,
TransferGateway, TransferGateway,
AutomationsGateway, AutomationsGateway,
SharingGateway,
TrackerRealtimeGateway, TrackerRealtimeGateway,
UsersGateway, UsersGateway,
} from '@task-stream/web/tracker/domain'; } 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 { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
import { HttpTransferGateway } from './adapters/http-transfer.gateway'; import { HttpTransferGateway } from './adapters/http-transfer.gateway';
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.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 * 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: WebhooksGateway, useClass: GraphqlWebhooksGateway },
{ provide: TransferGateway, useClass: HttpTransferGateway }, { provide: TransferGateway, useClass: HttpTransferGateway },
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway }, { provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
{ provide: SharingGateway, useClass: GraphqlSharingGateway },
]); ]);
} }

View File

@@ -1,9 +1,15 @@
import { import {
afterRenderEffect,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
DestroyRef,
DOCUMENT,
effect, effect,
ElementRef,
HostListener,
inject, inject,
signal,
untracked, untracked,
} from '@angular/core'; } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
@@ -34,6 +40,12 @@ import { BacklogHealthComponent } from '../ui/insights/backlog-health';
import { takeLastVisit } from '../ui/last-visit'; import { takeLastVisit } from '../ui/last-visit';
import { QualityComponent } from '../ui/insights/quality'; import { QualityComponent } from '../ui/insights/quality';
import { FieldBreakdownComponent } from '../ui/insights/field-breakdown'; import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
import { JourneyComponent } from '../ui/insights/journey';
import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
/** Seconds each block stays in «Modo pantalla». */
const SLIDE_SECONDS = 15;
/** "Seguimiento": the state of the whole project at a glance (live). */ /** "Seguimiento": the state of the whole project at a glance (live). */
@Component({ @Component({
@@ -53,9 +65,53 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
BacklogHealthComponent, BacklogHealthComponent,
QualityComponent, QualityComponent,
FieldBreakdownComponent, FieldBreakdownComponent,
ActivityHeatmapComponent,
JourneyComponent,
ProposalsFunnelComponent,
], ],
providers: [provideInsightsFacade()], providers: [provideInsightsFacade()],
host: { '[class.is-screen]': 'screen()' },
template: ` template: `
@if (screen()) {
<header class="ts-screen-bar">
<span class="ts-screen-bar__project">
<strong>{{ facade.project()?.key }}</strong>
{{ facade.project()?.name }}
</span>
<span class="ts-screen-bar__dots">
@for (n of slideNumbers(); track n) {
<button
type="button"
[class.is-current]="n === current()"
[attr.aria-label]="'Ir al bloque ' + (n + 1)"
(click)="go(n)"
></button>
}
</span>
<span class="ts-screen-bar__live">
<span class="ts-screen-bar__dot" aria-hidden="true"></span>
En directo
</span>
<time class="ts-screen-bar__clock">{{ clock() }}</time>
<button type="button" class="ts-plain-link" (click)="togglePause()">
{{ paused() ? 'Seguir' : 'Pausa' }}
</button>
<button type="button" class="ts-plain-link" (click)="exitScreen()">
Salir
</button>
</header>
} @else {
<div class="ts-screen-open">
<button
type="button"
class="ts-plain-link"
title="A pantalla completa, un bloque cada vez, para una tele del equipo"
(click)="enterScreen()"
>
Modo pantalla
</button>
</div>
}
@if (facade.stats(); as stats) { @if (facade.stats(); as stats) {
<section class="ts-kpis" aria-label="Resumen"> <section class="ts-kpis" aria-label="Resumen">
@for (kpi of kpis(); track kpi.label) { @for (kpi of kpis(); track kpi.label) {
@@ -132,11 +188,19 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
[statuses]="reading.bottlenecks" [statuses]="reading.bottlenecks"
[workflow]="facade.workflow()" [workflow]="facade.workflow()"
/> />
<ts-journey
[journey]="reading.journey"
[workflow]="facade.workflow()"
/>
<ts-activity-heatmap [days]="reading.heatmap" />
<ts-backlog-health [health]="reading.backlog" /> <ts-backlog-health [health]="reading.backlog" />
<ts-quality <ts-quality
[quality]="reading.quality" [quality]="reading.quality"
[workflow]="facade.workflow()" [workflow]="facade.workflow()"
/> />
@if (reading.proposals; as proposals) {
<ts-proposals-funnel [funnel]="proposals" />
}
@if (reading.fields.length) { @if (reading.fields.length) {
<ts-field-breakdown <ts-field-breakdown
[fields]="reading.fields" [fields]="reading.fields"
@@ -248,6 +312,89 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
} }
`, `,
styles: ` styles: `
.ts-screen-open {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
/* Modo pantalla: over everything, one block at a time, larger. */
:host(.is-screen) {
position: fixed;
inset: 0;
z-index: 2000;
display: block;
overflow: auto;
padding: 1.25rem 2.5rem 2.5rem;
background: var(--ts-bg);
}
:host(.is-screen) .ts-dashboard,
:host(.is-screen) .ts-insights {
display: block;
}
:host(.is-screen) .ts-slide-off {
display: none !important;
}
:host(.is-screen) .ts-slide-on {
max-width: 64rem;
margin: 0 auto 1.25rem;
zoom: 1.5;
}
.ts-screen-bar {
display: flex;
align-items: center;
gap: 1.25rem;
margin-bottom: 1.5rem;
font-size: 1.125rem;
}
.ts-screen-bar__project {
margin-right: auto;
white-space: nowrap;
strong {
font-family: var(--ts-font-mono);
margin-right: 0.5rem;
}
}
.ts-screen-bar__dots {
display: flex;
gap: 0.375rem;
/* Plain dots: past the app's button styles. */
button {
all: unset;
width: 0.625rem !important;
min-width: 0 !important;
height: 0.625rem !important;
padding: 0 !important;
border-radius: 50%;
background: var(--ts-line-strong);
cursor: pointer;
&.is-current {
background: var(--ts-brand);
}
&:focus-visible {
outline: 2px solid var(--ts-brand);
outline-offset: 2px;
}
}
}
.ts-screen-bar__live {
display: inline-flex;
white-space: nowrap;
align-items: center;
gap: 0.5rem;
color: var(--ts-muted);
font-size: 0.875rem;
font-weight: 600;
}
.ts-screen-bar__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ts-c-green);
}
.ts-screen-bar__clock {
font-family: var(--ts-font-mono);
font-weight: 600;
}
/* Same stat row as pireon.dev's hero. */ /* Same stat row as pireon.dev's hero. */
.ts-kpis { .ts-kpis {
display: grid; display: grid;
@@ -466,11 +613,130 @@ import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
export class DashboardPage { export class DashboardPage {
protected readonly facade = inject(ProjectFacade); protected readonly facade = inject(ProjectFacade);
protected readonly insights = inject(InsightsFacade); protected readonly insights = inject(InsightsFacade);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly document = inject(DOCUMENT);
/* --- Modo pantalla: for a team's TV, a block at a time, rotating --- */
protected readonly screen = signal(false);
protected readonly paused = signal(false);
private readonly slide = signal(0);
private readonly slides = signal(0);
protected readonly current = computed(() =>
this.slides() ? this.slide() % this.slides() : 0,
);
protected readonly slideNumbers = computed(() =>
Array.from({ length: this.slides() }, (_, n) => n),
);
protected readonly clock = signal('');
private timer: ReturnType<typeof setInterval> | null = null;
private ticks = 0;
private readonly marks = afterRenderEffect(() => {
this.screen();
this.current();
this.insights.insights();
this.markSlides();
});
protected enterScreen(): void {
this.screen.set(true);
this.paused.set(false);
this.slide.set(0);
this.updateClock();
this.host.nativeElement.requestFullscreen?.().catch(() => undefined);
this.timer = setInterval(() => this.tick(), 1000);
}
protected exitScreen(): void {
if (!this.screen()) return;
this.screen.set(false);
if (this.timer) clearInterval(this.timer);
this.timer = null;
if (this.document.fullscreenElement)
this.document.exitFullscreen().catch(() => undefined);
}
protected togglePause(): void {
this.paused.update((p) => !p);
this.ticks = 0;
}
protected go(n: number): void {
this.slide.set(n);
this.ticks = 0;
}
/** Every second: the clock, and every 15 s (unless paused) the next block. */
private tick(): void {
this.updateClock();
if (this.paused()) return;
if (++this.ticks >= SLIDE_SECONDS) {
this.ticks = 0;
this.slide.update((n) => n + 1);
}
}
private updateClock(): void {
this.clock.set(
new Date().toLocaleTimeString('es', {
hour: '2-digit',
minute: '2-digit',
}),
);
}
/**
* The blocks to rotate: the figures and the flow together first, then
* every panel on its own. Shown or hidden by a class, so the live
* updates keep landing on the same elements.
*/
private markSlides(): void {
const root = this.host.nativeElement;
const groups: Element[][] = [];
const lead = [
...root.querySelectorAll(':scope > .ts-kpis, :scope > .ts-flow'),
];
if (lead.length) groups.push(lead);
root
.querySelectorAll(':scope > .ts-dashboard > *')
.forEach((panel) => groups.push([panel]));
this.slides.set(groups.length);
const on = this.screen();
groups.forEach((group, n) =>
group.forEach((el) => {
el.classList.toggle('ts-slide-on', on && n === this.current());
el.classList.toggle('ts-slide-off', on && n !== this.current());
}),
);
}
@HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void {
if (!this.screen()) return;
if (event.key === 'ArrowRight') this.go(this.current() + 1);
else if (event.key === 'ArrowLeft')
this.go(
(this.current() - 1 + this.slides()) % Math.max(1, this.slides()),
);
else if (event.key === ' ') this.togglePause();
else if (event.key === 'Escape') this.exitScreen();
else return;
event.preventDefault();
}
/** Leaving the browser's full screen (Esc) leaves the mode too. */
@HostListener('document:fullscreenchange')
protected onFullscreenChange(): void {
if (!this.document.fullscreenElement) this.exitScreen();
}
/** Opened once per project: the project itself changes on every update. */ /** Opened once per project: the project itself changes on every update. */
private openedFor: string | null = null; private openedFor: string | null = null;
constructor() { constructor() {
inject(DestroyRef).onDestroy(() => {
if (this.timer) clearInterval(this.timer);
});
effect(() => { effect(() => {
const projectId = this.facade.project()?.id; const projectId = this.facade.project()?.id;
if (!projectId || projectId === this.openedFor) return; if (!projectId || projectId === this.openedFor) return;

View File

@@ -22,6 +22,7 @@ import { GitIntegrationComponent } from '../ui/git-integration';
import { WebhooksComponent } from '../ui/webhooks'; import { WebhooksComponent } from '../ui/webhooks';
import { TransferComponent } from '../ui/transfer'; import { TransferComponent } from '../ui/transfer';
import { AutomationsComponent } from '../ui/automations'; import { AutomationsComponent } from '../ui/automations';
import { ClientShareComponent } from '../ui/client-share';
import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectFacade } from '@task-stream/web/tracker/application';
import { import {
CustomFieldType, CustomFieldType,
@@ -58,7 +59,7 @@ const SECTIONS: SettingsSection[] = [
{ {
id: 'general', id: 'general',
label: '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', id: 'workflow',
@@ -143,6 +144,7 @@ function idFrom(name: string): string {
WebhooksComponent, WebhooksComponent,
TransferComponent, TransferComponent,
AutomationsComponent, AutomationsComponent,
ClientShareComponent,
CdkDropList, CdkDropList,
CdkDrag, CdkDrag,
CdkDragHandle, CdkDragHandle,
@@ -257,6 +259,10 @@ function idFrom(name: string): string {
}}</span> }}</span>
</label> </label>
</section> </section>
@if (facade.canManage()) {
<ts-client-share [projectId]="project.id" />
}
} }
@if (draft(); as d) { @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: () => loadComponent: () =>
import('./pages/explore.page').then((m) => m.ExplorePage), 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. // The Spanish paths, from links shared before.
{ path: 'cartera', redirectTo: 'portfolio' }, { path: 'cartera', redirectTo: 'portfolio' },
{ path: 'explorar', redirectTo: 'explore' }, { 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

@@ -0,0 +1,200 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
const WEEKDAYS = [
'lunes',
'martes',
'miércoles',
'jueves',
'viernes',
'sábado',
'domingo',
];
const MONTHS = [
'ene',
'feb',
'mar',
'abr',
'may',
'jun',
'jul',
'ago',
'sep',
'oct',
'nov',
'dic',
];
/** 12 weeks of the project's activity, a square a day: lulls and rushes at a glance. */
@Component({
selector: 'ts-activity-heatmap',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'heatmap-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="heatmap-title">Actividad</h2>
<span class="ts-muted">Movimientos por día, últimas 12 semanas</span>
</header>
<div class="ts-heat">
<span class="ts-heat__corner"></span>
@for (week of weeks(); track week.start) {
<span class="ts-heat__month">{{ week.month }}</span>
}
@for (row of [0, 1, 2, 3, 4, 5, 6]; track row) {
<span class="ts-heat__weekday">{{
row % 2 === 0 ? weekdayInitial(row) : ''
}}</span>
@for (week of weeks(); track week.start) {
@if (week.days[row]; as day) {
<span
class="ts-heat__day"
[attr.data-level]="day.level"
[class.is-future]="day.future"
[attr.title]="day.title"
></span>
}
}
}
</div>
<p class="ts-empty ts-heat__note">
@if (total()) {
{{ total() }} {{ total() === 1 ? 'movimiento' : 'movimientos' }} · el
día con más vida, el {{ busiest() }}
@if (quiet() >= 3) {
· parón más largo: {{ quiet() }} días
}
} @else {
Nada se ha movido en estas 12 semanas.
}
</p>
`,
styles: `
.ts-heat {
display: grid;
grid-template-columns: 1.25rem repeat(12, minmax(0, 1fr));
gap: 3px;
align-items: center;
}
.ts-heat__month,
.ts-heat__weekday {
color: var(--ts-muted);
font-size: 0.6875rem;
line-height: 1;
white-space: nowrap;
}
.ts-heat__month {
padding-bottom: 0.25rem;
}
.ts-heat__day {
aspect-ratio: 1;
max-height: 1.375rem;
border-radius: 3px;
background: var(--ts-sunken);
&[data-level='1'] {
background: color-mix(in srgb, var(--ts-brand) 25%, var(--ts-sunken));
}
&[data-level='2'] {
background: color-mix(in srgb, var(--ts-brand) 50%, var(--ts-sunken));
}
&[data-level='3'] {
background: color-mix(in srgb, var(--ts-brand) 75%, var(--ts-sunken));
}
&[data-level='4'] {
background: var(--ts-brand);
}
&.is-future {
background: transparent;
outline: 1px dashed var(--ts-line);
outline-offset: -1px;
}
}
.ts-heat__note {
margin-top: 0.875rem;
}
`,
})
export class ActivityHeatmapComponent {
/** 84 days from a Monday, oldest first. */
readonly days = input.required<{ day: string; count: number }[]>();
/** Today (`YYYY-MM-DD`), to grey out the rest of this week. */
readonly today = input<string>(new Date().toISOString().slice(0, 10));
protected readonly total = computed(() =>
this.days().reduce((sum, d) => sum + d.count, 0),
);
/** Squares coloured by quarters of the busiest day. */
protected readonly weeks = computed(() => {
const days = this.days();
const max = Math.max(1, ...days.map((d) => d.count));
const weeks: {
start: string;
month: string;
days: { level: number; future: boolean; title: string }[];
}[] = [];
for (let w = 0; w * 7 < days.length; w++) {
const week = days.slice(w * 7, w * 7 + 7);
const first = week[0].day;
// The month's name over the week it starts in (and over the first one).
const month =
w === 0 || week.some((d) => d.day.endsWith('-01'))
? MONTHS[
Number(
(week.find((d) => d.day.endsWith('-01')) ?? week[0]).day.slice(
5,
7,
),
) - 1
]
: '';
weeks.push({
start: first,
month,
days: week.map((d) => {
const future = d.day > this.today();
return {
future,
level: d.count ? Math.min(4, Math.ceil((d.count / max) * 4)) : 0,
title: future
? ''
: `${d.day.slice(8)}/${d.day.slice(5, 7)}: ${d.count} ${d.count === 1 ? 'movimiento' : 'movimientos'}`,
};
}),
});
}
return weeks;
});
protected readonly busiest = computed(() => {
const byWeekday = Array<number>(7).fill(0);
this.days().forEach((d, i) => (byWeekday[i % 7] += d.count));
return WEEKDAYS[byWeekday.indexOf(Math.max(...byWeekday))];
});
/** The longest run of days without anything moving, from the first that had something up to today. */
protected readonly quiet = computed(() => {
let longest = 0;
let run = 0;
const days = this.days();
const first = days.findIndex((d) => d.count > 0);
for (const d of first < 0 ? [] : days.slice(first)) {
if (d.day > this.today()) break;
run = d.count ? 0 : run + 1;
longest = Math.max(longest, run);
}
return longest;
});
protected weekdayInitial(row: number): string {
return ['L', 'M', 'X', 'J', 'V', 'S', 'D'][row];
}
}

View File

@@ -0,0 +1,146 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Journey, Workflow } from '@task-stream/web/tracker/domain';
import { statusLook } from '../copy';
/**
* Where a ticket's time goes from created to done, waiting included: one
* bar split by status, and how much of it was actual work.
*/
@Component({
selector: 'ts-journey',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ts-panel',
'aria-labelledby': 'journey-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="journey-title">El recorrido de un ticket</h2>
<span class="ts-muted">De creado a hecho, los cerrados en 90 días</span>
</header>
@if (journey().leadDays !== null && steps().length) {
<p class="ts-journey__lead">
<strong>{{ days(journey().leadDays!) }}</strong> de media ·
<strong>{{ journey().flowEfficiency }} %</strong> trabajando, el resto
esperando
</p>
<div class="ts-journey__bar" role="img" [attr.aria-label]="summary()">
@for (step of steps(); track step.status) {
<span
[style.--tone]="step.tone"
[style.flex-grow]="step.share || 1"
[attr.title]="
step.label + ': ' + days(step.days) + ' (' + step.share + ' %)'
"
></span>
}
</div>
<ul class="ts-issues ts-journey__legend">
@for (step of steps(); track step.status) {
<li [style.--tone]="step.tone" [class.is-longest]="step.longest">
<span class="ts-journey__dot" aria-hidden="true"></span>
<span class="ts-title">{{ step.label }}</span>
<span class="ts-days"
>{{ days(step.days) }} · {{ step.share }} %</span
>
</li>
}
</ul>
<p class="ts-empty ts-journey__note">
Medido en {{ journey().samples }}
{{ journey().samples === 1 ? 'ticket' : 'tickets' }}.
@if (longest(); as longest) {
Donde más tiempo se va: <strong>{{ longest }}</strong
>.
}
</p>
} @else {
<p class="ts-empty">
Aún no se ha terminado ningún ticket en los últimos 90 días.
</p>
}
`,
styles: `
.ts-journey__lead {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
}
.ts-journey__bar {
display: flex;
gap: 2px;
height: 1rem;
border-radius: 0.375rem;
overflow: hidden;
span {
min-width: 3px;
background: var(--tone);
}
}
.ts-journey__legend {
margin-top: 0.875rem;
gap: 0.375rem;
li {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
}
li.is-longest .ts-title {
font-weight: 600;
}
}
.ts-journey__dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--tone);
}
.ts-journey__note {
margin-top: 0.875rem;
}
`,
})
export class JourneyComponent {
readonly journey = input.required<Journey>();
readonly workflow = input.required<Workflow>();
protected readonly steps = computed(() => {
const steps = this.journey().steps;
const most = Math.max(0, ...steps.map((s) => s.share));
return steps.map((s) => {
const look = statusLook(this.workflow(), s.status);
return {
...s,
label: look.label,
tone: look.tone,
longest: s.share === most,
};
});
});
protected readonly longest = computed(() => {
const step = this.steps().find((s) => s.longest);
return step ? `${step.label} (${step.share} %)` : null;
});
protected readonly summary = computed(() =>
this.steps()
.map((s) => `${s.label} ${s.share} %`)
.join(', '),
);
/** Days, or hours when under one (or «menos de 1 h»). */
protected days(n: number): string {
if (n < 1) {
const hours = Math.round(n * 24);
return hours < 1 ? 'menos de 1 h' : `${hours} h`;
}
const rounded = Math.round(n * 10) / 10;
return `${rounded.toLocaleString('es')} ${rounded === 1 ? 'día' : 'días'}`;
}
}

View File

@@ -0,0 +1,98 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { ProposalsFunnel } from '@task-stream/web/tracker/domain';
/** The public's proposals: how many came, where they are, and the most voted still unanswered. */
@Component({
selector: 'ts-proposals-funnel',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
host: {
class: 'ts-panel',
'aria-labelledby': 'proposals-title',
role: 'region',
},
styleUrl: './insight-panel.scss',
template: `
<header class="ts-panel__head">
<h2 id="proposals-title">Propuestas del público</h2>
<span class="ts-muted"
>{{ funnel().receivedLately }} en los últimos 30 días</span
>
</header>
<ul class="ts-issues ts-funnel">
@for (stage of stages(); track stage.label) {
<li [style.--tone]="stage.tone">
<span class="ts-title">{{ stage.label }}</span>
<span class="ts-bar"
><span [style.width.%]="stage.width"></span
></span>
<span class="ts-days">{{ stage.count }}</span>
</li>
}
</ul>
@if (funnel().topWaiting.length) {
<h3 class="ts-funnel__subtitle">Las más votadas sin respuesta</h3>
<ul class="ts-issues">
@for (p of funnel().topWaiting; track p.id) {
<li>
<a class="ts-key" [routerLink]="['../issues', p.key]">{{
p.key
}}</a>
<a class="ts-title" [routerLink]="['../issues', p.key]">{{
p.title
}}</a>
<span class="ts-days" [attr.title]="p.days + ' días esperando'"
>▲ {{ p.votes }} · {{ p.days }} d</span
>
</li>
}
</ul>
} @else if (funnel().received) {
<p class="ts-empty ts-funnel__note">Ninguna espera respuesta.</p>
}
`,
styles: `
.ts-funnel {
gap: 0.5rem;
li {
grid-template-columns: minmax(6rem, 8rem) 1fr 2.5rem;
align-items: center;
}
.ts-days {
text-align: right;
}
}
.ts-funnel__subtitle {
margin: 1.125rem 0 0.5rem;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ts-funnel__note {
margin-top: 0.875rem;
}
`,
})
export class ProposalsFunnelComponent {
readonly funnel = input.required<ProposalsFunnel>();
protected readonly stages = computed(() => {
const f = this.funnel();
const max = Math.max(1, f.received);
return [
{ label: 'Recibidas', count: f.received, tone: 'var(--ts-brand)' },
{ label: 'Esperando', count: f.waiting, tone: 'var(--ts-c-amber)' },
{ label: 'En marcha', count: f.underWay, tone: 'var(--ts-c-blue)' },
{ label: 'Hechas', count: f.done, tone: 'var(--ts-c-green)' },
{ label: 'Descartadas', count: f.discarded, tone: 'var(--ts-c-slate)' },
].map((s) => ({ ...s, width: (s.count / max) * 100 }));
});
}

View File

@@ -61,7 +61,11 @@ export function verdictOf(release: ReleaseForecast): {
<ul class="ts-issues ts-forecast"> <ul class="ts-issues ts-forecast">
@for (row of rows(); track row.releaseId) { @for (row of rows(); track row.releaseId) {
<li> <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">{{ <span class="ts-verdict" [style.--tone]="row.tone">{{
row.label row.label
}}</span> }}</span>
@@ -110,6 +114,8 @@ export function verdictOf(release: ReleaseForecast): {
}) })
export class ReleaseForecastComponent { export class ReleaseForecastComponent {
readonly releases = input.required<ReleaseForecast[]>(); 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(() => protected readonly rows = computed(() =>
this.releases().map((r) => ({ this.releases().map((r) => ({

View File

@@ -52,7 +52,9 @@ const decimal = (n: number) =>
@if (paceText(); as pace) { @if (paceText(); as pace) {
{{ pace }} {{ pace }}
} }
<a routerLink="../sprints">Ver el sprint →</a> @if (linked()) {
<a routerLink="../sprints">Ver el sprint →</a>
}
</p> </p>
`, `,
styles: ` styles: `
@@ -105,6 +107,8 @@ const decimal = (n: number) =>
}) })
export class SprintPulseComponent { export class SprintPulseComponent {
readonly sprint = input.required<SprintPulse>(); 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( protected readonly when = computed(
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`, () => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,