feat: a home page with life
Greeting and summary of the day, the coming week, the latest across my projects, who is online now, the week in numbers (mine and the team's, with a streak), sprints under way, «Retomar», project cards with their activity, what to pick up when nothing is mine and first steps for newcomers. REST (GET /api/me/home), GraphQL (myHome) and MCP (get_my_week). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1904,4 +1904,71 @@ describe('task-stream (e2e)', () => {
|
|||||||
(d) => !d['portfolio'].some((p: { key: string }) => p.key === key),
|
(d) => !d['portfolio'].some((p: { key: string }) => p.key === key),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fills the home page: agenda, activity, the week and who is here', async () => {
|
||||||
|
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||||
|
userId: '102',
|
||||||
|
});
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const ticket = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'TASK',
|
||||||
|
title: 'Para hoy',
|
||||||
|
assigneeId: '101',
|
||||||
|
dueDate: today,
|
||||||
|
})
|
||||||
|
).body['key'];
|
||||||
|
const issue = await eventually(
|
||||||
|
() => rest('token-bob', 'GET', `/issues/${ticket}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
// Bob is on it right now.
|
||||||
|
await rest('token-bob', 'PUT', `/issues/${issue.body['id']}/presence`, {
|
||||||
|
editing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const home = await eventually(
|
||||||
|
() => rest('token-ana', 'GET', '/me/home'),
|
||||||
|
(r) =>
|
||||||
|
r.body?.agenda?.some(
|
||||||
|
(a: { issueKey: string }) => a.issueKey === ticket,
|
||||||
|
) &&
|
||||||
|
r.body.activity.some(
|
||||||
|
(a: { issueKey: string }) => a.issueKey === ticket,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(home.body.agenda).toContainEqual(
|
||||||
|
expect.objectContaining({ day: today, kind: 'ISSUE', issueKey: ticket }),
|
||||||
|
);
|
||||||
|
expect(home.body.online).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
user: { id: '102', name: 'Bob (frontend)' },
|
||||||
|
projectKey: key,
|
||||||
|
issueKey: ticket,
|
||||||
|
editing: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(home.body.team.perDay).toHaveLength(14);
|
||||||
|
expect(home.body.firstSteps).toMatchObject({
|
||||||
|
project: true,
|
||||||
|
invited: true,
|
||||||
|
ticket: true,
|
||||||
|
});
|
||||||
|
const card = home.body.projects.find(
|
||||||
|
(p: { projectId: string }) => p.projectId === issue.body['projectId'],
|
||||||
|
);
|
||||||
|
expect(card).toMatchObject({ open: 1 });
|
||||||
|
expect(card.activity).toHaveLength(14);
|
||||||
|
const { myHome } = await gql(
|
||||||
|
'token-ana',
|
||||||
|
`query { myHome { me { thisWeek streak } suggestions { kind } } }`,
|
||||||
|
);
|
||||||
|
expect(myHome.me.streak).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2185,6 +2185,77 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/me/home": {
|
||||||
|
"get": {
|
||||||
|
"description": "Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now.",
|
||||||
|
"operationId": "IssuesController_home",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HomeDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 home page beyond my work",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/me/portfolio": {
|
"/api/me/portfolio": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Every active project I am a member of (not archived, not as a guest): open and at-risk tickets, the sprint under way, release forecasts and open epics.",
|
"description": "Every active project I am a member of (not archived, not as a guest): open and at-risk tickets, the sprint under way, release forecasts and open epics.",
|
||||||
@@ -13490,6 +13561,300 @@
|
|||||||
"role"
|
"role"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"AgendaItemDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"day": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ISSUE",
|
||||||
|
"SPRINT_END",
|
||||||
|
"RELEASE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"projectKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"issueKey": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"day",
|
||||||
|
"kind",
|
||||||
|
"projectKey",
|
||||||
|
"title",
|
||||||
|
"issueKey"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TeamWeekDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"perDay": {
|
||||||
|
"description": "Finished per day, last 14, oldest first",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thisWeek": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastWeek": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"perDay",
|
||||||
|
"thisWeek",
|
||||||
|
"lastWeek"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MyWeekDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"thisWeek": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lastWeek": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"streak": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Days in a row with something finished"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"thisWeek",
|
||||||
|
"lastWeek",
|
||||||
|
"streak"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HomeSprintDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"projectName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sprint": {
|
||||||
|
"$ref": "#/components/schemas/SprintPulseDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"projectKey",
|
||||||
|
"projectName",
|
||||||
|
"sprint"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HomeSprintBriefDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"percent": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"behind": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"percent",
|
||||||
|
"behind"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HomeProjectDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"open": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"overdue": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"description": "Activity per day, last 14, oldest first",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sprint": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/HomeSprintBriefDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"projectId",
|
||||||
|
"open",
|
||||||
|
"overdue",
|
||||||
|
"activity",
|
||||||
|
"sprint"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SuggestionDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"UNASSIGNED",
|
||||||
|
"PROPOSALS",
|
||||||
|
"QA_WAITING"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"projectKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"projectKey",
|
||||||
|
"count"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"FirstStepsDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"invited": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project",
|
||||||
|
"invited",
|
||||||
|
"ticket",
|
||||||
|
"calendar"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OnlineDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"user": {
|
||||||
|
"$ref": "#/components/schemas/UserRefDto"
|
||||||
|
},
|
||||||
|
"projectKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"issueKey": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"issueTitle": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"editing": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"at": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"user",
|
||||||
|
"projectKey",
|
||||||
|
"issueKey",
|
||||||
|
"issueTitle",
|
||||||
|
"editing",
|
||||||
|
"at"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HomeDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agenda": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AgendaItemDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ActivityDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"$ref": "#/components/schemas/TeamWeekDto"
|
||||||
|
},
|
||||||
|
"me": {
|
||||||
|
"$ref": "#/components/schemas/MyWeekDto"
|
||||||
|
},
|
||||||
|
"sprints": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/HomeSprintDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/HomeProjectDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SuggestionDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"firstSteps": {
|
||||||
|
"$ref": "#/components/schemas/FirstStepsDto"
|
||||||
|
},
|
||||||
|
"online": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/OnlineDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"agenda",
|
||||||
|
"activity",
|
||||||
|
"team",
|
||||||
|
"me",
|
||||||
|
"sprints",
|
||||||
|
"projects",
|
||||||
|
"suggestions",
|
||||||
|
"firstSteps",
|
||||||
|
"online"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ChildProgressDto": {
|
"ChildProgressDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ input AddChecklistItemInput {
|
|||||||
text: String!
|
text: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""Something on a day of my week"""
|
||||||
|
type AgendaItem {
|
||||||
|
"""YYYY-MM-DD"""
|
||||||
|
day: String!
|
||||||
|
issueKey: String
|
||||||
|
|
||||||
|
"""ISSUE, SPRINT_END or RELEASE"""
|
||||||
|
kind: String!
|
||||||
|
projectKey: String!
|
||||||
|
title: String!
|
||||||
|
}
|
||||||
|
|
||||||
type Aging {
|
type Aging {
|
||||||
issues: [InsightIssue!]!
|
issues: [InsightIssue!]!
|
||||||
|
|
||||||
@@ -507,6 +519,13 @@ type FieldBreakdown {
|
|||||||
none: Int!
|
none: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FirstSteps {
|
||||||
|
calendar: Boolean!
|
||||||
|
invited: Boolean!
|
||||||
|
project: Boolean!
|
||||||
|
ticket: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
type FlowSeries {
|
type FlowSeries {
|
||||||
counts: [Int!]!
|
counts: [Int!]!
|
||||||
status: String!
|
status: String!
|
||||||
@@ -536,6 +555,40 @@ type GitRepository {
|
|||||||
secret: String
|
secret: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""The home page beyond my work"""
|
||||||
|
type Home {
|
||||||
|
activity: [Activity!]!
|
||||||
|
agenda: [AgendaItem!]!
|
||||||
|
firstSteps: FirstSteps!
|
||||||
|
me: MyWeek!
|
||||||
|
online: [Online!]!
|
||||||
|
projects: [HomeProject!]!
|
||||||
|
sprints: [HomeSprint!]!
|
||||||
|
suggestions: [Suggestion!]!
|
||||||
|
team: TeamWeek!
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeProject {
|
||||||
|
"""Activity per day, last 14, oldest first"""
|
||||||
|
activity: [Int!]!
|
||||||
|
open: Int!
|
||||||
|
overdue: Int!
|
||||||
|
projectId: ID!
|
||||||
|
sprint: HomeSprintBrief
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeSprint {
|
||||||
|
projectKey: String!
|
||||||
|
projectName: String!
|
||||||
|
sprint: SprintPulse!
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeSprintBrief {
|
||||||
|
behind: Boolean!
|
||||||
|
name: String!
|
||||||
|
percent: Int!
|
||||||
|
}
|
||||||
|
|
||||||
"""A ticket the dashboard names, with the number that put it there"""
|
"""A ticket the dashboard names, with the number that put it there"""
|
||||||
type InsightIssue {
|
type InsightIssue {
|
||||||
assignee: UserRef
|
assignee: UserRef
|
||||||
@@ -1035,6 +1088,14 @@ input MyViewCriteriaInput {
|
|||||||
watching: Boolean
|
watching: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MyWeek {
|
||||||
|
lastWeek: Int!
|
||||||
|
|
||||||
|
"""Days in a row with something finished"""
|
||||||
|
streak: Int!
|
||||||
|
thisWeek: Int!
|
||||||
|
}
|
||||||
|
|
||||||
type Notification {
|
type Notification {
|
||||||
actor: UserRef!
|
actor: UserRef!
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
@@ -1077,6 +1138,16 @@ type NotificationSettings {
|
|||||||
pushPublicKey: String
|
pushPublicKey: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""Someone of my projects on a ticket now"""
|
||||||
|
type Online {
|
||||||
|
at: String!
|
||||||
|
editing: Boolean!
|
||||||
|
issueKey: String
|
||||||
|
issueTitle: String
|
||||||
|
projectKey: String!
|
||||||
|
user: UserRef!
|
||||||
|
}
|
||||||
|
|
||||||
type OptionCount {
|
type OptionCount {
|
||||||
count: Int!
|
count: Int!
|
||||||
value: String!
|
value: String!
|
||||||
@@ -1348,6 +1419,11 @@ type Query {
|
|||||||
"""My calendar URL, if I made one"""
|
"""My calendar URL, if I made one"""
|
||||||
myCalendarFeed: CalendarFeed
|
myCalendarFeed: CalendarFeed
|
||||||
|
|
||||||
|
"""
|
||||||
|
The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now
|
||||||
|
"""
|
||||||
|
myHome: Home!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||||
"""
|
"""
|
||||||
@@ -1712,6 +1788,14 @@ type Subscription {
|
|||||||
sprintChanged(projectId: ID!): SprintChange!
|
sprintChanged(projectId: ID!): SprintChange!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Suggestion {
|
||||||
|
count: Int!
|
||||||
|
|
||||||
|
"""UNASSIGNED, PROPOSALS or QA_WAITING"""
|
||||||
|
kind: String!
|
||||||
|
projectKey: String!
|
||||||
|
}
|
||||||
|
|
||||||
"""A group of members: tickets go to it; @team mentions tell them all"""
|
"""A group of members: tickets go to it; @team mentions tell them all"""
|
||||||
type Team {
|
type Team {
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
@@ -1738,6 +1822,14 @@ type TeamLoad {
|
|||||||
user: UserRef!
|
user: UserRef!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TeamWeek {
|
||||||
|
lastWeek: Int!
|
||||||
|
|
||||||
|
"""Finished per day, last 14, oldest first"""
|
||||||
|
perDay: [Int!]!
|
||||||
|
thisWeek: Int!
|
||||||
|
}
|
||||||
|
|
||||||
type Throughput {
|
type Throughput {
|
||||||
count: Int!
|
count: Int!
|
||||||
user: UserRef
|
user: UserRef
|
||||||
|
|||||||
@@ -397,6 +397,26 @@ export interface paths {
|
|||||||
patch: operations["ProjectsController_changeMemberRole"];
|
patch: operations["ProjectsController_changeMemberRole"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/me/home": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* The home page beyond my work
|
||||||
|
* @description Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now.
|
||||||
|
*/
|
||||||
|
get: operations["IssuesController_home"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/me/portfolio": {
|
"/api/me/portfolio": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2460,6 +2480,77 @@ export interface components {
|
|||||||
ChangeMemberRoleDto: {
|
ChangeMemberRoleDto: {
|
||||||
role: components["schemas"]["ProjectRole"];
|
role: components["schemas"]["ProjectRole"];
|
||||||
};
|
};
|
||||||
|
AgendaItemDto: {
|
||||||
|
/** Format: date */
|
||||||
|
day: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
kind: "ISSUE" | "SPRINT_END" | "RELEASE";
|
||||||
|
projectKey: string;
|
||||||
|
title: string;
|
||||||
|
issueKey: string | null;
|
||||||
|
};
|
||||||
|
TeamWeekDto: {
|
||||||
|
/** @description Finished per day, last 14, oldest first */
|
||||||
|
perDay: number[];
|
||||||
|
thisWeek: number;
|
||||||
|
lastWeek: number;
|
||||||
|
};
|
||||||
|
MyWeekDto: {
|
||||||
|
thisWeek: number;
|
||||||
|
lastWeek: number;
|
||||||
|
/** @description Days in a row with something finished */
|
||||||
|
streak: number;
|
||||||
|
};
|
||||||
|
HomeSprintDto: {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
sprint: components["schemas"]["SprintPulseDto"];
|
||||||
|
};
|
||||||
|
HomeSprintBriefDto: {
|
||||||
|
name: string;
|
||||||
|
percent: number;
|
||||||
|
behind: boolean;
|
||||||
|
};
|
||||||
|
HomeProjectDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
projectId: string;
|
||||||
|
open: number;
|
||||||
|
overdue: number;
|
||||||
|
/** @description Activity per day, last 14, oldest first */
|
||||||
|
activity: number[];
|
||||||
|
sprint: components["schemas"]["HomeSprintBriefDto"] | null;
|
||||||
|
};
|
||||||
|
SuggestionDto: {
|
||||||
|
/** @enum {string} */
|
||||||
|
kind: "UNASSIGNED" | "PROPOSALS" | "QA_WAITING";
|
||||||
|
projectKey: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
FirstStepsDto: {
|
||||||
|
project: boolean;
|
||||||
|
invited: boolean;
|
||||||
|
ticket: boolean;
|
||||||
|
calendar: boolean;
|
||||||
|
};
|
||||||
|
OnlineDto: {
|
||||||
|
user: components["schemas"]["UserRefDto"];
|
||||||
|
projectKey: string;
|
||||||
|
issueKey: string | null;
|
||||||
|
issueTitle: string | null;
|
||||||
|
editing: boolean;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
HomeDto: {
|
||||||
|
agenda: components["schemas"]["AgendaItemDto"][];
|
||||||
|
activity: components["schemas"]["ActivityDto"][];
|
||||||
|
team: components["schemas"]["TeamWeekDto"];
|
||||||
|
me: components["schemas"]["MyWeekDto"];
|
||||||
|
sprints: components["schemas"]["HomeSprintDto"][];
|
||||||
|
projects: components["schemas"]["HomeProjectDto"][];
|
||||||
|
suggestions: components["schemas"]["SuggestionDto"][];
|
||||||
|
firstSteps: components["schemas"]["FirstStepsDto"];
|
||||||
|
online: components["schemas"]["OnlineDto"][];
|
||||||
|
};
|
||||||
ChildProgressDto: {
|
ChildProgressDto: {
|
||||||
total: number;
|
total: number;
|
||||||
/** @description Done or cancelled */
|
/** @description Done or cancelled */
|
||||||
@@ -4816,6 +4907,61 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
IssuesController_home: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HomeDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
IssuesController_portfolio: {
|
IssuesController_portfolio: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -204,6 +204,27 @@ export class ProjectsResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_my_week',
|
||||||
|
description:
|
||||||
|
"The user's coming week and how the last one went: tickets of theirs (assigned or watched) due in the next 7 days, sprint ends and planned releases; tickets finished by them and by their teams this week and last, and their streak; sprints under way; and, when nothing is theirs, what they could pick up (unassigned, waiting for QA, proposals to review).",
|
||||||
|
paramsSchema: z.object({}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
myWeek(_params: Record<string, never>, ctx: McpContext) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const home = await this.api.data(this.api.as(ctx).GET('/api/me/home'));
|
||||||
|
// Compact: the page's activity feed, presence and first steps stay out.
|
||||||
|
return {
|
||||||
|
agenda: home.agenda,
|
||||||
|
me: home.me,
|
||||||
|
team: { thisWeek: home.team.thisWeek, lastWeek: home.team.lastWeek },
|
||||||
|
sprints: home.sprints,
|
||||||
|
suggestions: home.suggestions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Tool({
|
@Tool({
|
||||||
name: 'get_portfolio',
|
name: 'get_portfolio',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -423,6 +423,20 @@ La **cartera** (`portfolio` en GraphQL, `GET /api/me/portfolio`) junta, para cad
|
|||||||
la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el
|
la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el
|
||||||
panel.
|
panel.
|
||||||
|
|
||||||
|
La **portada** (`myHome` en GraphQL, `GET /api/me/home`) junta lo demás que se ve en la página de inicio
|
||||||
|
(`analytics/home.ts`):
|
||||||
|
|
||||||
|
| Bloque | Qué da |
|
||||||
|
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `agenda` | Los próximos 7 días: mis tickets abiertos (asignados o que sigo) que vencen, fin de sprints en curso y releases planificadas |
|
||||||
|
| `activity` | Lo último en mis proyectos (30 entradas; sin los proyectos en los que soy invitado) |
|
||||||
|
| `team` / `me` | Tickets terminados por día en los últimos 14 días; esta semana y la anterior, de mis equipos y míos (por quien lo tenía asignado), y mi racha |
|
||||||
|
| `sprints` | Los sprints en curso, con el mismo pulso que el panel (¿llega?) |
|
||||||
|
| `projects` | Por proyecto: abiertos, vencidos, actividad por día (14 días) y el sprint en curso con su porcentaje |
|
||||||
|
| `suggestions` | Para cuando no tengo nada: sin asignar, esperando QA y propuestas por revisar (estas, solo a quien administra); las 5 mayores |
|
||||||
|
| `firstSteps` | Si ya creé un proyecto, invité a alguien, abrí un ticket y conecté el calendario |
|
||||||
|
| `online` | Quién está ahora en un ticket de mis proyectos (por la presencia, una entrada por persona) |
|
||||||
|
|
||||||
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
||||||
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
||||||
|
|
||||||
@@ -679,10 +693,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
|||||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
||||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ Qué gana la página:
|
|||||||
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
||||||
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
||||||
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
|
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
|
||||||
|
| `MyHomeFacade` | `MyHomeStore` | raíz | El resto de la portada (`myHome`): agenda, actividad, cifras de la semana, sprints, tarjetas, sugerencias, primeros pasos y quién está; se recarga cada minuto y al volver a la pestaña |
|
||||||
| `MyViewsFacade` | `MyViewsStore` | raíz | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
|
| `MyViewsFacade` | `MyViewsStore` | raíz | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
|
||||||
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
|
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
|
||||||
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
|
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
|
||||||
@@ -285,7 +286,7 @@ visualización de la plataforma:
|
|||||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `/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: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos |
|
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, la semana (`ts-week-agenda`), mis vistas guardadas, mi trabajo (`ts-my-work`), lo último en mis proyectos, la semana en cifras, sprints en curso, quién está ahora, «Retomar» y mis proyectos con su actividad |
|
||||||
| `/cartera` | 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 |
|
| `/cartera` | 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 |
|
||||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
| `/explorar` | 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) |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ flowchart LR
|
|||||||
|
|
||||||
## 9.2 Herramientas
|
## 9.2 Herramientas
|
||||||
|
|
||||||
57 herramientas, agrupadas por ámbito:
|
60 herramientas, agrupadas por ámbito:
|
||||||
|
|
||||||
| Ámbito | Herramienta | Qué hace |
|
| Ámbito | Herramienta | Qué hace |
|
||||||
| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -49,6 +49,7 @@ flowchart LR
|
|||||||
| | `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_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 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_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 |
|
||||||
|
|||||||
@@ -122,3 +122,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
|
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
|
||||||
abre tickets y comenta en los suyos.
|
abre tickets y comenta en los suyos.
|
||||||
- Vista de cartera (`/cartera`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas.
|
- Vista de cartera (`/cartera`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas.
|
||||||
|
- 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,
|
||||||
|
qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`).
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
215
libs/api/tracker/application/src/lib/analytics/home.spec.ts
Normal file
215
libs/api/tracker/application/src/lib/analytics/home.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
IssueTimelineEntry,
|
||||||
|
ProjectReadModel,
|
||||||
|
ProjectRole,
|
||||||
|
ReleaseReadModel,
|
||||||
|
ReleaseStatus,
|
||||||
|
SprintReadModel,
|
||||||
|
SprintStatus,
|
||||||
|
StatusCategory as C,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeHome, HomeProjectInput, lastDays } from './home';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-10-15T12:00:00Z');
|
||||||
|
const ana = { id: 'ana', name: 'Ana' };
|
||||||
|
const bob = { id: 'bob', name: 'Bob' };
|
||||||
|
|
||||||
|
const project = (
|
||||||
|
key: string,
|
||||||
|
role: ProjectRole,
|
||||||
|
extra: Partial<ProjectReadModel> = {},
|
||||||
|
): ProjectReadModel =>
|
||||||
|
({
|
||||||
|
id: key.toLowerCase(),
|
||||||
|
key,
|
||||||
|
name: `Proyecto ${key}`,
|
||||||
|
isPublic: false,
|
||||||
|
archivedAt: null,
|
||||||
|
members: [
|
||||||
|
{ user: ana, role },
|
||||||
|
{ user: bob, role: ProjectRole.MEMBER },
|
||||||
|
],
|
||||||
|
...extra,
|
||||||
|
}) as ProjectReadModel;
|
||||||
|
|
||||||
|
const issue = (
|
||||||
|
id: string,
|
||||||
|
category: C,
|
||||||
|
extra: Partial<IssueReadModel> = {},
|
||||||
|
): IssueReadModel =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
key: id,
|
||||||
|
projectId: 'web',
|
||||||
|
title: `Ticket ${id}`,
|
||||||
|
status: category,
|
||||||
|
statusCategory: category,
|
||||||
|
reporter: bob,
|
||||||
|
assignee: null,
|
||||||
|
watchers: [],
|
||||||
|
labels: [],
|
||||||
|
dueDate: null,
|
||||||
|
trashedAt: null,
|
||||||
|
storyPoints: null,
|
||||||
|
sprint: null,
|
||||||
|
createdAt: '2026-09-01T09:00:00Z',
|
||||||
|
updatedAt: '2026-10-01T09:00:00Z',
|
||||||
|
...extra,
|
||||||
|
}) as IssueReadModel;
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const done = (
|
||||||
|
issueId: string,
|
||||||
|
at: string,
|
||||||
|
assigneeId: string,
|
||||||
|
): IssueTimelineEntry[] => [
|
||||||
|
{
|
||||||
|
id: `${issueId}:${++seq}`,
|
||||||
|
issueId,
|
||||||
|
projectId: 'web',
|
||||||
|
at: '2026-09-01T09:00:00Z',
|
||||||
|
kind: 'status',
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
category: C.IN_PROGRESS,
|
||||||
|
assigneeId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${issueId}:${++seq}`,
|
||||||
|
issueId,
|
||||||
|
projectId: 'web',
|
||||||
|
at,
|
||||||
|
kind: 'status',
|
||||||
|
status: 'DONE',
|
||||||
|
category: C.DONE,
|
||||||
|
assigneeId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const input = (projects: HomeProjectInput[]) => ({
|
||||||
|
viewerId: 'ana',
|
||||||
|
projects,
|
||||||
|
activity: [],
|
||||||
|
hasCalendar: false,
|
||||||
|
online: [],
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
|
||||||
|
const web = (extra: Partial<HomeProjectInput> = {}): HomeProjectInput => ({
|
||||||
|
project: project('WEB', ProjectRole.OWNER),
|
||||||
|
issues: [],
|
||||||
|
timeline: [],
|
||||||
|
sprints: [],
|
||||||
|
releases: [],
|
||||||
|
activity: new Array(14).fill(0),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeHome', () => {
|
||||||
|
it('puts my coming week on the agenda: tickets, sprint ends, releases', () => {
|
||||||
|
const home = computeHome(
|
||||||
|
input([
|
||||||
|
web({
|
||||||
|
issues: [
|
||||||
|
issue('MINE', C.TODO, { assignee: ana, dueDate: '2026-10-17' }),
|
||||||
|
issue('FOLLOWED', C.TODO, {
|
||||||
|
watchers: [ana],
|
||||||
|
dueDate: '2026-10-15',
|
||||||
|
}),
|
||||||
|
issue('NOT_MINE', C.TODO, { assignee: bob, dueDate: '2026-10-16' }),
|
||||||
|
issue('LATER', C.TODO, { assignee: ana, dueDate: '2026-10-30' }),
|
||||||
|
issue('DONE', C.DONE, { assignee: ana, dueDate: '2026-10-16' }),
|
||||||
|
],
|
||||||
|
sprints: [
|
||||||
|
{
|
||||||
|
id: 's',
|
||||||
|
name: 'Sprint 7',
|
||||||
|
status: SprintStatus.ACTIVE,
|
||||||
|
startDate: '2026-10-08',
|
||||||
|
endDate: '2026-10-21',
|
||||||
|
} as SprintReadModel,
|
||||||
|
],
|
||||||
|
releases: [
|
||||||
|
{
|
||||||
|
id: 'r',
|
||||||
|
name: '2.4',
|
||||||
|
status: ReleaseStatus.PLANNED,
|
||||||
|
targetDate: '2026-10-20',
|
||||||
|
} as ReleaseReadModel,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(home.agenda.map((a) => [a.day, a.kind, a.title])).toEqual([
|
||||||
|
['2026-10-15', 'ISSUE', 'Ticket FOLLOWED'],
|
||||||
|
['2026-10-17', 'ISSUE', 'Ticket MINE'],
|
||||||
|
['2026-10-20', 'RELEASE', '2.4'],
|
||||||
|
['2026-10-21', 'SPRINT_END', 'Sprint 7'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the team's week and mine, and my streak", () => {
|
||||||
|
const closed = [
|
||||||
|
['A', '2026-10-15T10:00:00Z', 'ana'],
|
||||||
|
['B', '2026-10-14T10:00:00Z', 'ana'],
|
||||||
|
['C', '2026-10-13T10:00:00Z', 'ana'],
|
||||||
|
['D', '2026-10-11T10:00:00Z', 'ana'],
|
||||||
|
['E', '2026-10-05T10:00:00Z', 'bob'],
|
||||||
|
] as const;
|
||||||
|
const home = computeHome(
|
||||||
|
input([
|
||||||
|
web({
|
||||||
|
issues: closed.map(([id]) => issue(id, C.DONE)),
|
||||||
|
timeline: closed.flatMap(([id, at, who]) => done(id, at, who)),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(home.team.perDay).toHaveLength(14);
|
||||||
|
expect(home.team.perDay[13]).toBe(1);
|
||||||
|
expect(home.team).toMatchObject({ thisWeek: 4, lastWeek: 1 });
|
||||||
|
// Today, yesterday and the day before: three in a row (the 12th breaks it).
|
||||||
|
expect(home.me).toEqual({ thisWeek: 4, lastWeek: 0, streak: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests what to do, and keeps a guest out of project-wide views', () => {
|
||||||
|
const home = computeHome(
|
||||||
|
input([
|
||||||
|
web({
|
||||||
|
issues: [
|
||||||
|
issue('U1', C.TODO),
|
||||||
|
issue('U2', C.TODO, { labels: ['propuesta'] }),
|
||||||
|
issue('Q1', C.QA, { assignee: bob }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
...web(),
|
||||||
|
project: project('CLI', ProjectRole.GUEST),
|
||||||
|
issues: [issue('X', C.TODO)],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
home.suggestions.map((s) => [s.kind, s.projectKey, s.count]),
|
||||||
|
).toEqual([
|
||||||
|
['UNASSIGNED', 'WEB', 2],
|
||||||
|
['QA_WAITING', 'WEB', 1],
|
||||||
|
['PROPOSALS', 'WEB', 1],
|
||||||
|
]);
|
||||||
|
// Only the project seen whole has a card.
|
||||||
|
expect(home.projects.map((p) => p.projectId)).toEqual(['web']);
|
||||||
|
expect(home.projects[0]).toMatchObject({ open: 3, overdue: 0 });
|
||||||
|
expect(home.firstSteps).toEqual({
|
||||||
|
project: true,
|
||||||
|
invited: true,
|
||||||
|
ticket: false,
|
||||||
|
calendar: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the last 14 days, oldest first', () => {
|
||||||
|
const days = lastDays(NOW);
|
||||||
|
expect(days).toHaveLength(14);
|
||||||
|
expect(days[0]).toBe('2026-10-02');
|
||||||
|
expect(days[13]).toBe('2026-10-15');
|
||||||
|
});
|
||||||
|
});
|
||||||
274
libs/api/tracker/application/src/lib/analytics/home.ts
Normal file
274
libs/api/tracker/application/src/lib/analytics/home.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import {
|
||||||
|
ActivityReadModel,
|
||||||
|
AgendaItemReadModel,
|
||||||
|
HomeProjectReadModel,
|
||||||
|
HomeReadModel,
|
||||||
|
HomeSprintReadModel,
|
||||||
|
IssueReadModel,
|
||||||
|
IssueTimelineEntry,
|
||||||
|
OnlineReadModel,
|
||||||
|
ProjectReadModel,
|
||||||
|
ProjectRole,
|
||||||
|
ReleaseReadModel,
|
||||||
|
ReleaseStatus,
|
||||||
|
SprintReadModel,
|
||||||
|
SprintStatus,
|
||||||
|
StatusCategory,
|
||||||
|
SuggestionReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { PROPOSAL_LABEL } from '../proposals';
|
||||||
|
import { guestOnly } from '../queries/read-access';
|
||||||
|
import { stepsByIssue } from './analytics';
|
||||||
|
import { sprintPulse } from './insights';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The home page beyond «my work»: what comes this week, what moves in my
|
||||||
|
* projects, how the week goes, and what to do when nothing is mine. Pure,
|
||||||
|
* like the statistics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||||
|
const OPEN = [
|
||||||
|
StatusCategory.TODO,
|
||||||
|
StatusCategory.IN_PROGRESS,
|
||||||
|
StatusCategory.QA,
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface HomeProjectInput {
|
||||||
|
project: ProjectReadModel;
|
||||||
|
issues: IssueReadModel[];
|
||||||
|
timeline: IssueTimelineEntry[];
|
||||||
|
sprints: SprintReadModel[];
|
||||||
|
releases: ReleaseReadModel[];
|
||||||
|
/** Activity per day of the last 14 days, oldest first. */
|
||||||
|
activity: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeInput {
|
||||||
|
viewerId: string;
|
||||||
|
projects: HomeProjectInput[];
|
||||||
|
/** Latest activity of the projects I see whole, newest first. */
|
||||||
|
activity: ActivityReadModel[];
|
||||||
|
hasCalendar: boolean;
|
||||||
|
online: OnlineReadModel[];
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcDay = (time: number) =>
|
||||||
|
Date.UTC(
|
||||||
|
new Date(time).getUTCFullYear(),
|
||||||
|
new Date(time).getUTCMonth(),
|
||||||
|
new Date(time).getUTCDate(),
|
||||||
|
);
|
||||||
|
const dayString = (time: number) => new Date(time).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export function computeHome(input: HomeInput): HomeReadModel {
|
||||||
|
const now = input.now.getTime();
|
||||||
|
const today = utcDay(now);
|
||||||
|
const todayStr = dayString(today);
|
||||||
|
const weekEnd = dayString(today + 6 * DAY);
|
||||||
|
const me = input.viewerId;
|
||||||
|
const whole = input.projects.filter((p) => !guestOnly(p.project, me));
|
||||||
|
|
||||||
|
// --- Agenda: the next 7 days ---
|
||||||
|
const agenda: AgendaItemReadModel[] = [];
|
||||||
|
for (const { project, issues, sprints, releases } of input.projects) {
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (issue.trashedAt || CLOSED.includes(issue.statusCategory)) continue;
|
||||||
|
const due = issue.dueDate?.slice(0, 10);
|
||||||
|
if (!due || due < todayStr || due > weekEnd) continue;
|
||||||
|
if (
|
||||||
|
issue.assignee?.id !== me &&
|
||||||
|
!(issue.watchers ?? []).some((w) => w.id === me)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
agenda.push({
|
||||||
|
day: due,
|
||||||
|
kind: 'ISSUE',
|
||||||
|
projectKey: project.key,
|
||||||
|
title: issue.title,
|
||||||
|
issueKey: issue.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (guestOnly(project, me)) continue;
|
||||||
|
for (const sprint of sprints)
|
||||||
|
if (
|
||||||
|
sprint.status === SprintStatus.ACTIVE &&
|
||||||
|
sprint.endDate >= todayStr &&
|
||||||
|
sprint.endDate <= weekEnd
|
||||||
|
)
|
||||||
|
agenda.push({
|
||||||
|
day: sprint.endDate,
|
||||||
|
kind: 'SPRINT_END',
|
||||||
|
projectKey: project.key,
|
||||||
|
title: sprint.name,
|
||||||
|
issueKey: null,
|
||||||
|
});
|
||||||
|
for (const release of releases) {
|
||||||
|
const target = release.targetDate?.slice(0, 10);
|
||||||
|
if (
|
||||||
|
release.status === ReleaseStatus.PLANNED &&
|
||||||
|
target &&
|
||||||
|
target >= todayStr &&
|
||||||
|
target <= weekEnd
|
||||||
|
)
|
||||||
|
agenda.push({
|
||||||
|
day: target,
|
||||||
|
kind: 'RELEASE',
|
||||||
|
projectKey: project.key,
|
||||||
|
title: release.name,
|
||||||
|
issueKey: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agenda.sort(
|
||||||
|
(a, b) => a.day.localeCompare(b.day) || a.kind.localeCompare(b.kind),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Finished per day: the team's and mine ---
|
||||||
|
const teamPerDay = new Array<number>(14).fill(0);
|
||||||
|
const minePerDay = new Map<number, number>();
|
||||||
|
for (const { issues, timeline } of whole) {
|
||||||
|
const steps = stepsByIssue(issues, timeline);
|
||||||
|
for (const list of steps.values())
|
||||||
|
list.forEach((step, i) => {
|
||||||
|
if (step.category !== StatusCategory.DONE) return;
|
||||||
|
if (i > 0 && list[i - 1].category === StatusCategory.DONE) return;
|
||||||
|
if (i === 0) return; // created already done: nobody finished it
|
||||||
|
const day = utcDay(step.at);
|
||||||
|
const ago = Math.round((today - day) / DAY);
|
||||||
|
if (ago >= 0 && ago < 14) teamPerDay[13 - ago]++;
|
||||||
|
if (step.assigneeId === me)
|
||||||
|
minePerDay.set(day, (minePerDay.get(day) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sum = (from: number, to: number) =>
|
||||||
|
teamPerDay.slice(from, to).reduce((a, b) => a + b, 0);
|
||||||
|
const mine = (daysAgoFrom: number, daysAgoTo: number) => {
|
||||||
|
let total = 0;
|
||||||
|
for (let ago = daysAgoFrom; ago < daysAgoTo; ago++)
|
||||||
|
total += minePerDay.get(today - ago * DAY) ?? 0;
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
// A streak counts up to today, or up to yesterday if today is still empty.
|
||||||
|
let streak = 0;
|
||||||
|
for (
|
||||||
|
let ago = minePerDay.get(today) ? 0 : 1;
|
||||||
|
minePerDay.get(today - ago * DAY);
|
||||||
|
ago++
|
||||||
|
)
|
||||||
|
streak++;
|
||||||
|
|
||||||
|
// --- Sprints and project cards ---
|
||||||
|
const sprints: HomeSprintReadModel[] = [];
|
||||||
|
const projects: HomeProjectReadModel[] = [];
|
||||||
|
for (const { project, issues, sprints: all, activity } of whole) {
|
||||||
|
const live = issues.filter((i) => !i.trashedAt);
|
||||||
|
const active = all.find((s) => s.status === SprintStatus.ACTIVE);
|
||||||
|
const pulse = active ? sprintPulse(active, live, now) : null;
|
||||||
|
if (pulse)
|
||||||
|
sprints.push({
|
||||||
|
projectKey: project.key,
|
||||||
|
projectName: project.name,
|
||||||
|
sprint: pulse,
|
||||||
|
});
|
||||||
|
projects.push({
|
||||||
|
projectId: project.id,
|
||||||
|
open: live.filter((i) => !CLOSED.includes(i.statusCategory)).length,
|
||||||
|
overdue: live.filter(
|
||||||
|
(i) =>
|
||||||
|
!CLOSED.includes(i.statusCategory) &&
|
||||||
|
!!i.dueDate &&
|
||||||
|
i.dueDate.slice(0, 10) < todayStr,
|
||||||
|
).length,
|
||||||
|
activity,
|
||||||
|
sprint: pulse
|
||||||
|
? {
|
||||||
|
name: pulse.name,
|
||||||
|
percent: pulse.points
|
||||||
|
? Math.round((pulse.donePoints / pulse.points) * 100)
|
||||||
|
: pulse.tickets
|
||||||
|
? Math.round((pulse.doneTickets / pulse.tickets) * 100)
|
||||||
|
: 0,
|
||||||
|
behind: pulse.behind,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Suggestions, for when nothing is mine ---
|
||||||
|
const suggestions: SuggestionReadModel[] = [];
|
||||||
|
for (const { project, issues } of whole) {
|
||||||
|
const role = project.members.find((m) => m.user.id === me)?.role;
|
||||||
|
if (!role || role === ProjectRole.VIEWER) continue;
|
||||||
|
const open = issues.filter(
|
||||||
|
(i) => !i.trashedAt && OPEN.includes(i.statusCategory),
|
||||||
|
);
|
||||||
|
const unassigned = open.filter((i) => !i.assignee).length;
|
||||||
|
if (unassigned)
|
||||||
|
suggestions.push({
|
||||||
|
kind: 'UNASSIGNED',
|
||||||
|
projectKey: project.key,
|
||||||
|
count: unassigned,
|
||||||
|
});
|
||||||
|
const qa = open.filter(
|
||||||
|
(i) => i.statusCategory === StatusCategory.QA,
|
||||||
|
).length;
|
||||||
|
if (qa)
|
||||||
|
suggestions.push({
|
||||||
|
kind: 'QA_WAITING',
|
||||||
|
projectKey: project.key,
|
||||||
|
count: qa,
|
||||||
|
});
|
||||||
|
if (role === ProjectRole.OWNER || role === ProjectRole.ADMIN) {
|
||||||
|
const proposals = open.filter(
|
||||||
|
(i) =>
|
||||||
|
i.statusCategory === StatusCategory.TODO &&
|
||||||
|
i.labels.includes(PROPOSAL_LABEL),
|
||||||
|
).length;
|
||||||
|
if (proposals)
|
||||||
|
suggestions.push({
|
||||||
|
kind: 'PROPOSALS',
|
||||||
|
projectKey: project.key,
|
||||||
|
count: proposals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suggestions.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agenda,
|
||||||
|
activity: input.activity,
|
||||||
|
team: { perDay: teamPerDay, thisWeek: sum(7, 14), lastWeek: sum(0, 7) },
|
||||||
|
me: { thisWeek: mine(0, 7), lastWeek: mine(7, 14), streak },
|
||||||
|
sprints,
|
||||||
|
projects,
|
||||||
|
suggestions: suggestions.slice(0, 5),
|
||||||
|
firstSteps: {
|
||||||
|
project: input.projects.length > 0,
|
||||||
|
invited: input.projects.some(
|
||||||
|
(p) =>
|
||||||
|
p.project.members.length > 1 &&
|
||||||
|
p.project.members.some(
|
||||||
|
(m) =>
|
||||||
|
m.user.id === me &&
|
||||||
|
(m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ticket: input.projects.some((p) =>
|
||||||
|
p.issues.some((i) => i.reporter?.id === me),
|
||||||
|
),
|
||||||
|
calendar: input.hasCalendar,
|
||||||
|
},
|
||||||
|
online: input.online,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last 14 UTC days (oldest first), as `YYYY-MM-DD`. */
|
||||||
|
export function lastDays(now: Date, count = 14): string[] {
|
||||||
|
const today = utcDay(now.getTime());
|
||||||
|
return Array.from({ length: count }, (_, n) =>
|
||||||
|
dayString(today - (count - 1 - n) * DAY),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -523,7 +523,7 @@ function backlogHealth(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sprintPulse(
|
export function sprintPulse(
|
||||||
sprint: SprintReadModel,
|
sprint: SprintReadModel,
|
||||||
issues: IssueReadModel[],
|
issues: IssueReadModel[],
|
||||||
now: number,
|
now: number,
|
||||||
|
|||||||
@@ -8,4 +8,14 @@ export abstract class FindActivityRepository {
|
|||||||
/** ISO date cursor: only activity strictly older than this. */
|
/** ISO date cursor: only activity strictly older than this. */
|
||||||
before?: string;
|
before?: string;
|
||||||
}): Promise<ActivityReadModel[]>;
|
}): Promise<ActivityReadModel[]>;
|
||||||
|
/** The latest of several projects, newest first. */
|
||||||
|
abstract findAcross(
|
||||||
|
projectIds: string[],
|
||||||
|
limit: number,
|
||||||
|
): Promise<ActivityReadModel[]>;
|
||||||
|
/** How much happened per project and day (`YYYY-MM-DD`) since `fromDay`. */
|
||||||
|
abstract dailyCounts(
|
||||||
|
projectIds: string[],
|
||||||
|
fromDay: string,
|
||||||
|
): Promise<{ projectId: string; day: string; count: number }[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface PresenceEntry {
|
|||||||
editing: boolean;
|
editing: boolean;
|
||||||
/** Last heartbeat (ISO). */
|
/** Last heartbeat (ISO). */
|
||||||
at: string;
|
at: string;
|
||||||
|
/** The ticket, in the project-wide list (who is on what). */
|
||||||
|
issue?: { key: string; title: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,4 +23,14 @@ export abstract class PresenceStore {
|
|||||||
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
|
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
|
||||||
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
|
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
|
||||||
abstract leave(issueId: string, userId: string): Promise<void>;
|
abstract leave(issueId: string, userId: string): Promise<void>;
|
||||||
|
/** The same, per project: who is on which of its tickets. */
|
||||||
|
abstract touchProject(
|
||||||
|
projectId: string,
|
||||||
|
entry: PresenceEntry,
|
||||||
|
ttlMs: number,
|
||||||
|
): Promise<void>;
|
||||||
|
abstract listProject(
|
||||||
|
projectId: string,
|
||||||
|
since: number,
|
||||||
|
): Promise<PresenceEntry[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class MemoryPresence extends PresenceStore {
|
|||||||
async leave(issueId: string, userId: string) {
|
async leave(issueId: string, userId: string) {
|
||||||
this.entries.get(issueId)?.delete(userId);
|
this.entries.get(issueId)?.delete(userId);
|
||||||
}
|
}
|
||||||
|
async touchProject() {
|
||||||
|
/* not needed here */
|
||||||
|
}
|
||||||
|
async listProject() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const issues = {
|
const issues = {
|
||||||
|
|||||||
@@ -28,17 +28,22 @@ export class PresenceService {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
editing: boolean,
|
editing: boolean,
|
||||||
): Promise<PresenceEntry[]> {
|
): Promise<PresenceEntry[]> {
|
||||||
await this.assertCanView(viewer.id, issueId);
|
const issue = await this.assertCanView(viewer.id, issueId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await this.store.touch(
|
const entry = {
|
||||||
issueId,
|
|
||||||
{
|
|
||||||
user: { id: viewer.id, name: viewer.name },
|
user: { id: viewer.id, name: viewer.name },
|
||||||
editing,
|
editing,
|
||||||
at: new Date(now).toISOString(),
|
at: new Date(now).toISOString(),
|
||||||
},
|
};
|
||||||
|
await Promise.all([
|
||||||
|
this.store.touch(issueId, entry, PRESENCE_TTL_MS),
|
||||||
|
// The project-wide list says on which ticket (home's «who is here»).
|
||||||
|
this.store.touchProject(
|
||||||
|
issue.projectId,
|
||||||
|
{ ...entry, issue: { key: issue.key, title: issue.title } },
|
||||||
PRESENCE_TTL_MS,
|
PRESENCE_TTL_MS,
|
||||||
);
|
),
|
||||||
|
]);
|
||||||
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
|
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
|
||||||
return here
|
return here
|
||||||
.filter((entry) => entry.user.id !== viewer.id)
|
.filter((entry) => entry.user.id !== viewer.id)
|
||||||
@@ -58,5 +63,6 @@ export class PresenceService {
|
|||||||
const issue = await this.findIssues.findById(issueId);
|
const issue = await this.findIssues.findById(issueId);
|
||||||
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||||
await this.access.assertCanViewIssue(issue, viewerId);
|
await this.access.assertCanViewIssue(issue, viewerId);
|
||||||
|
return issue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ProjectAnalyticsReadModel,
|
ProjectAnalyticsReadModel,
|
||||||
ProjectInsightsReadModel,
|
ProjectInsightsReadModel,
|
||||||
PortfolioProjectReadModel,
|
PortfolioProjectReadModel,
|
||||||
|
HomeReadModel,
|
||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
@@ -40,6 +41,7 @@ import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
|||||||
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
||||||
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
|
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
|
||||||
import { GetPortfolioQuery } from './queries/get-portfolio.query';
|
import { GetPortfolioQuery } from './queries/get-portfolio.query';
|
||||||
|
import { GetMyHomeQuery } from './queries/get-my-home.query';
|
||||||
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||||
import { GetProjectQuery } from './queries/get-project.query';
|
import { GetProjectQuery } from './queries/get-project.query';
|
||||||
@@ -339,6 +341,11 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
||||||
|
/** The home page beyond «my work». */
|
||||||
|
home(viewerId: string): Promise<HomeReadModel> {
|
||||||
|
return this.queryBus.execute(new GetMyHomeQuery(viewerId));
|
||||||
|
}
|
||||||
|
|
||||||
/** All my active projects at a glance (not archived, not as a guest). */
|
/** All my active projects at a glance (not archived, not as a guest). */
|
||||||
portfolio(viewerId: string): Promise<PortfolioProjectReadModel[]> {
|
portfolio(viewerId: string): Promise<PortfolioProjectReadModel[]> {
|
||||||
return this.queryBus.execute(new GetPortfolioQuery(viewerId));
|
return this.queryBus.execute(new GetPortfolioQuery(viewerId));
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
HomeReadModel,
|
||||||
|
OnlineReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeHome, HomeProjectInput, lastDays } from '../analytics/home';
|
||||||
|
import { CalendarFeedsRepository } from '../ports/calendar-feeds.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';
|
||||||
|
import { PresenceStore } from '../ports/presence.store';
|
||||||
|
import { PRESENCE_TTL_MS } from '../presence.service';
|
||||||
|
import { GetMyHomeQuery } from './get-my-home.query';
|
||||||
|
import { guestOnly } from './read-access';
|
||||||
|
|
||||||
|
/** How much activity the home page lists. */
|
||||||
|
const ACTIVITY = 30;
|
||||||
|
|
||||||
|
@QueryHandler(GetMyHomeQuery)
|
||||||
|
export class GetMyHomeQueryHandler
|
||||||
|
implements IQueryHandler<GetMyHomeQuery, HomeReadModel>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly findProjects: FindProjectsRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly findTimeline: FindTimelineRepository,
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly findReleases: FindReleasesRepository,
|
||||||
|
private readonly findActivity: FindActivityRepository,
|
||||||
|
private readonly feeds: CalendarFeedsRepository,
|
||||||
|
private readonly presence: PresenceStore,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetMyHomeQuery): Promise<HomeReadModel> {
|
||||||
|
const me = query.viewerId;
|
||||||
|
const now = new Date();
|
||||||
|
const projects = (await this.findProjects.findByMember(me)).filter(
|
||||||
|
(p) => !p.archivedAt,
|
||||||
|
);
|
||||||
|
// Project-wide views only of the projects I see whole (not as a guest).
|
||||||
|
const whole = projects.filter((p) => !guestOnly(p, me));
|
||||||
|
const days = lastDays(now);
|
||||||
|
const [inputs, activity, counts, feed, online] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
projects.map(async (project) => {
|
||||||
|
const [issues, timeline, sprints, releases] = await Promise.all([
|
||||||
|
this.findIssues.findByProject(project.id),
|
||||||
|
this.findTimeline.findByProject(project.id),
|
||||||
|
this.findSprints.findByProject(project.id),
|
||||||
|
this.findReleases.findByProject(project.id),
|
||||||
|
]);
|
||||||
|
return { project, issues, timeline, sprints, releases };
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
this.findActivity.findAcross(
|
||||||
|
whole.map((p) => p.id),
|
||||||
|
ACTIVITY,
|
||||||
|
),
|
||||||
|
this.findActivity.dailyCounts(
|
||||||
|
whole.map((p) => p.id),
|
||||||
|
days[0],
|
||||||
|
),
|
||||||
|
this.feeds.findByUser(me),
|
||||||
|
Promise.all(
|
||||||
|
whole.map(async (project) =>
|
||||||
|
(
|
||||||
|
await this.presence.listProject(
|
||||||
|
project.id,
|
||||||
|
now.getTime() - PRESENCE_TTL_MS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((entry) => entry.user.id !== me)
|
||||||
|
.map(
|
||||||
|
(entry): OnlineReadModel => ({
|
||||||
|
user: entry.user,
|
||||||
|
projectKey: project.key,
|
||||||
|
issueKey: entry.issue?.key ?? null,
|
||||||
|
issueTitle: entry.issue?.title ?? null,
|
||||||
|
editing: entry.editing,
|
||||||
|
at: entry.at,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const perDay = (projectId: string) =>
|
||||||
|
days.map(
|
||||||
|
(day) =>
|
||||||
|
counts.find((c) => c.projectId === projectId && c.day === day)
|
||||||
|
?.count ?? 0,
|
||||||
|
);
|
||||||
|
return computeHome({
|
||||||
|
viewerId: me,
|
||||||
|
projects: inputs.map(
|
||||||
|
(i): HomeProjectInput => ({ ...i, activity: perDay(i.project.id) }),
|
||||||
|
),
|
||||||
|
activity,
|
||||||
|
hasCalendar: !!feed,
|
||||||
|
// One line per person: the latest ticket they are on.
|
||||||
|
online: online
|
||||||
|
.flat()
|
||||||
|
.sort((a, b) => b.at.localeCompare(a.at))
|
||||||
|
.filter(
|
||||||
|
(entry, i, all) =>
|
||||||
|
all.findIndex((e) => e.user.id === entry.user.id) === i,
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Query } from '@nestjs/cqrs';
|
||||||
|
import { HomeReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home page beyond «my work»: agenda, activity, the week, sprints,
|
||||||
|
* project cards, suggestions, first steps and who is on what now.
|
||||||
|
*/
|
||||||
|
export class GetMyHomeQuery extends Query<HomeReadModel> {
|
||||||
|
constructor(public readonly viewerId: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export * from './get-project-analytics.query';
|
|||||||
export * from './get-project-insights.query';
|
export * from './get-project-insights.query';
|
||||||
export * from './get-ticket-scope.query';
|
export * from './get-ticket-scope.query';
|
||||||
export * from './get-portfolio.query';
|
export * from './get-portfolio.query';
|
||||||
|
export * from './get-my-home.query';
|
||||||
export * from './get-project-stats.query';
|
export * from './get-project-stats.query';
|
||||||
export * from './get-release-burnup.query';
|
export * from './get-release-burnup.query';
|
||||||
export * from './get-project.query';
|
export * from './get-project.query';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-h
|
|||||||
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
||||||
import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler';
|
import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler';
|
||||||
import { GetPortfolioQueryHandler } from './get-portfolio.query-handler';
|
import { GetPortfolioQueryHandler } from './get-portfolio.query-handler';
|
||||||
|
import { GetMyHomeQueryHandler } from './get-my-home.query-handler';
|
||||||
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
||||||
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
||||||
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
||||||
@@ -40,6 +41,7 @@ export const QUERY_HANDLERS = [
|
|||||||
GetProjectInsightsQueryHandler,
|
GetProjectInsightsQueryHandler,
|
||||||
GetTicketScopeQueryHandler,
|
GetTicketScopeQueryHandler,
|
||||||
GetPortfolioQueryHandler,
|
GetPortfolioQueryHandler,
|
||||||
|
GetMyHomeQueryHandler,
|
||||||
GetProjectStatsQueryHandler,
|
GetProjectStatsQueryHandler,
|
||||||
GetReleaseBurnupQueryHandler,
|
GetReleaseBurnupQueryHandler,
|
||||||
GetReleaseQueryHandler,
|
GetReleaseQueryHandler,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ActivityReadModel } from './activity.read-model';
|
||||||
|
import { SprintPulseReadModel } from './insights.read-model';
|
||||||
|
import { UserRefReadModel } from './user-ref.read-model';
|
||||||
|
|
||||||
|
/** Something on a day of my coming week. */
|
||||||
|
export class AgendaItemReadModel {
|
||||||
|
/** `YYYY-MM-DD`. */
|
||||||
|
day!: string;
|
||||||
|
/** A ticket of mine due, a sprint ending or a release planned. */
|
||||||
|
kind!: 'ISSUE' | 'SPRINT_END' | 'RELEASE';
|
||||||
|
projectKey!: string;
|
||||||
|
title!: string;
|
||||||
|
/** For tickets. */
|
||||||
|
issueKey!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last 7 days against the 7 before. */
|
||||||
|
export class WeekCountReadModel {
|
||||||
|
thisWeek!: number;
|
||||||
|
lastWeek!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets finished by the teams of my projects. */
|
||||||
|
export class TeamWeekReadModel extends WeekCountReadModel {
|
||||||
|
/** The last 14 days, oldest first. */
|
||||||
|
perDay!: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets I finished (they were mine when they reached DONE). */
|
||||||
|
export class MyWeekReadModel extends WeekCountReadModel {
|
||||||
|
/** Days in a row, up to today (or yesterday), with at least one. */
|
||||||
|
streak!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sprint under way in one of my projects. */
|
||||||
|
export class HomeSprintReadModel {
|
||||||
|
projectKey!: string;
|
||||||
|
projectName!: string;
|
||||||
|
sprint!: SprintPulseReadModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a project's card on the home page shows besides its name. */
|
||||||
|
export class HomeProjectReadModel {
|
||||||
|
projectId!: string;
|
||||||
|
open!: number;
|
||||||
|
overdue!: number;
|
||||||
|
/** Activity per day, the last 14 days, oldest first. */
|
||||||
|
activity!: number[];
|
||||||
|
sprint!: { name: string; percent: number; behind: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something worth doing when nothing is assigned to me. */
|
||||||
|
export class SuggestionReadModel {
|
||||||
|
kind!: 'UNASSIGNED' | 'PROPOSALS' | 'QA_WAITING';
|
||||||
|
projectKey!: string;
|
||||||
|
count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first things to do in TaskStream, done or not. */
|
||||||
|
export class FirstStepsReadModel {
|
||||||
|
project!: boolean;
|
||||||
|
invited!: boolean;
|
||||||
|
ticket!: boolean;
|
||||||
|
calendar!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Someone of my projects on a ticket right now. */
|
||||||
|
export class OnlineReadModel {
|
||||||
|
user!: UserRefReadModel;
|
||||||
|
projectKey!: string;
|
||||||
|
issueKey!: string | null;
|
||||||
|
issueTitle!: string | null;
|
||||||
|
editing!: boolean;
|
||||||
|
at!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the home page shows beyond «my work». */
|
||||||
|
export class HomeReadModel {
|
||||||
|
agenda!: AgendaItemReadModel[];
|
||||||
|
/** Newest first, across my projects. */
|
||||||
|
activity!: ActivityReadModel[];
|
||||||
|
team!: TeamWeekReadModel;
|
||||||
|
me!: MyWeekReadModel;
|
||||||
|
sprints!: HomeSprintReadModel[];
|
||||||
|
projects!: HomeProjectReadModel[];
|
||||||
|
suggestions!: SuggestionReadModel[];
|
||||||
|
firstSteps!: FirstStepsReadModel;
|
||||||
|
online!: OnlineReadModel[];
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ export * from './notification.read-model';
|
|||||||
export * from './saved-filter.read-model';
|
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';
|
||||||
|
|||||||
@@ -614,6 +614,47 @@ export class MongoFindActivityRepository extends FindActivityRepository {
|
|||||||
.lean<ActivityDoc[]>();
|
.lean<ActivityDoc[]>();
|
||||||
return docs.map(fromDoc);
|
return docs.map(fromDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAcross(
|
||||||
|
projectIds: string[],
|
||||||
|
limit: number,
|
||||||
|
): Promise<ActivityReadModel[]> {
|
||||||
|
if (!projectIds.length) return [];
|
||||||
|
const docs = await this.activity
|
||||||
|
.find({ projectId: { $in: projectIds } })
|
||||||
|
.sort({ occurredAt: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.lean<ActivityDoc[]>();
|
||||||
|
return docs.map(fromDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dailyCounts(
|
||||||
|
projectIds: string[],
|
||||||
|
fromDay: string,
|
||||||
|
): Promise<{ projectId: string; day: string; count: number }[]> {
|
||||||
|
if (!projectIds.length) return [];
|
||||||
|
const rows = await this.activity.aggregate<{
|
||||||
|
_id: { projectId: string; day: string };
|
||||||
|
count: number;
|
||||||
|
}>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
projectId: { $in: projectIds },
|
||||||
|
occurredAt: { $gte: fromDay },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
projectId: '$projectId',
|
||||||
|
day: { $substrCP: ['$occurredAt', 0, 10] },
|
||||||
|
},
|
||||||
|
count: { $sum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return rows.map((r) => ({ ...r._id, count: r.count }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -20,12 +20,35 @@ export class RedisPresenceStore extends PresenceStore {
|
|||||||
return `task-stream:presence:${issueId}`;
|
return `task-stream:presence:${issueId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async touch(
|
private projectKey(projectId: string): string {
|
||||||
issueId: string,
|
return `task-stream:presence:project:${projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchProject(
|
||||||
|
projectId: string,
|
||||||
|
entry: PresenceEntry,
|
||||||
|
ttlMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.write(this.projectKey(projectId), entry, ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
listProject(projectId: string, since: number): Promise<PresenceEntry[]> {
|
||||||
|
return this.read(this.projectKey(projectId), since);
|
||||||
|
}
|
||||||
|
|
||||||
|
touch(issueId: string, entry: PresenceEntry, ttlMs: number): Promise<void> {
|
||||||
|
return this.write(this.key(issueId), entry, ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(issueId: string, since: number): Promise<PresenceEntry[]> {
|
||||||
|
return this.read(this.key(issueId), since);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async write(
|
||||||
|
key: string,
|
||||||
entry: PresenceEntry,
|
entry: PresenceEntry,
|
||||||
ttlMs: number,
|
ttlMs: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = this.key(issueId);
|
|
||||||
await this.redis
|
await this.redis
|
||||||
.multi()
|
.multi()
|
||||||
.hset(key, entry.user.id, JSON.stringify(entry))
|
.hset(key, entry.user.id, JSON.stringify(entry))
|
||||||
@@ -33,8 +56,7 @@ export class RedisPresenceStore extends PresenceStore {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(issueId: string, since: number): Promise<PresenceEntry[]> {
|
private async read(key: string, since: number): Promise<PresenceEntry[]> {
|
||||||
const key = this.key(issueId);
|
|
||||||
const all = await this.redis.hgetall(key);
|
const all = await this.redis.hgetall(key);
|
||||||
const fresh: PresenceEntry[] = [];
|
const fresh: PresenceEntry[] = [];
|
||||||
const stale: string[] = [];
|
const stale: string[] = [];
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
ProjectAnalyticsGql,
|
ProjectAnalyticsGql,
|
||||||
ProjectInsightsGql,
|
ProjectInsightsGql,
|
||||||
PortfolioProjectGql,
|
PortfolioProjectGql,
|
||||||
|
HomeGql,
|
||||||
ProjectGql,
|
ProjectGql,
|
||||||
ProjectSettingsGql,
|
ProjectSettingsGql,
|
||||||
ProjectStatsGql,
|
ProjectStatsGql,
|
||||||
@@ -48,6 +49,14 @@ export class ProjectResolver {
|
|||||||
return this.projects.findMine(me.id);
|
return this.projects.findMine(me.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Query(() => HomeGql, {
|
||||||
|
description:
|
||||||
|
'The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now',
|
||||||
|
})
|
||||||
|
myHome(@CurrentUser() me: User) {
|
||||||
|
return this.projects.home(me.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Query(() => [PortfolioProjectGql], {
|
@Query(() => [PortfolioProjectGql], {
|
||||||
description:
|
description:
|
||||||
'My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics',
|
'My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics',
|
||||||
|
|||||||
@@ -1097,3 +1097,95 @@ export class PortfolioProjectGql {
|
|||||||
epics!: EpicGql[];
|
epics!: EpicGql[];
|
||||||
@Field() updatedAt!: string;
|
@Field() updatedAt!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('AgendaItem', { description: 'Something on a day of my week' })
|
||||||
|
export class AgendaItemGql {
|
||||||
|
@Field({ description: 'YYYY-MM-DD' }) day!: string;
|
||||||
|
@Field({ description: 'ISSUE, SPRINT_END or RELEASE' }) kind!: string;
|
||||||
|
@Field() projectKey!: string;
|
||||||
|
@Field() title!: string;
|
||||||
|
@Field(() => String, { nullable: true }) issueKey!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TeamWeek')
|
||||||
|
export class TeamWeekGql {
|
||||||
|
@Field(() => [Int], {
|
||||||
|
description: 'Finished per day, last 14, oldest first',
|
||||||
|
})
|
||||||
|
perDay!: number[];
|
||||||
|
@Field(() => Int) thisWeek!: number;
|
||||||
|
@Field(() => Int) lastWeek!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('MyWeek')
|
||||||
|
export class MyWeekGql {
|
||||||
|
@Field(() => Int) thisWeek!: number;
|
||||||
|
@Field(() => Int) lastWeek!: number;
|
||||||
|
@Field(() => Int, { description: 'Days in a row with something finished' })
|
||||||
|
streak!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('HomeSprint')
|
||||||
|
export class HomeSprintGql {
|
||||||
|
@Field() projectKey!: string;
|
||||||
|
@Field() projectName!: string;
|
||||||
|
@Field(() => SprintPulseGql) sprint!: SprintPulseGql;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('HomeSprintBrief')
|
||||||
|
export class HomeSprintBriefGql {
|
||||||
|
@Field() name!: string;
|
||||||
|
@Field(() => Int) percent!: number;
|
||||||
|
@Field() behind!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('HomeProject')
|
||||||
|
export class HomeProjectGql {
|
||||||
|
@Field(() => ID) projectId!: string;
|
||||||
|
@Field(() => Int) open!: number;
|
||||||
|
@Field(() => Int) overdue!: number;
|
||||||
|
@Field(() => [Int], {
|
||||||
|
description: 'Activity per day, last 14, oldest first',
|
||||||
|
})
|
||||||
|
activity!: number[];
|
||||||
|
@Field(() => HomeSprintBriefGql, { nullable: true })
|
||||||
|
sprint!: HomeSprintBriefGql | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Suggestion')
|
||||||
|
export class SuggestionGql {
|
||||||
|
@Field({ description: 'UNASSIGNED, PROPOSALS or QA_WAITING' }) kind!: string;
|
||||||
|
@Field() projectKey!: string;
|
||||||
|
@Field(() => Int) count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('FirstSteps')
|
||||||
|
export class FirstStepsGql {
|
||||||
|
@Field() project!: boolean;
|
||||||
|
@Field() invited!: boolean;
|
||||||
|
@Field() ticket!: boolean;
|
||||||
|
@Field() calendar!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Online', { description: 'Someone of my projects on a ticket now' })
|
||||||
|
export class OnlineGql {
|
||||||
|
@Field(() => UserRefGql) user!: UserRefGql;
|
||||||
|
@Field() projectKey!: string;
|
||||||
|
@Field(() => String, { nullable: true }) issueKey!: string | null;
|
||||||
|
@Field(() => String, { nullable: true }) issueTitle!: string | null;
|
||||||
|
@Field() editing!: boolean;
|
||||||
|
@Field() at!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Home', { description: 'The home page beyond my work' })
|
||||||
|
export class HomeGql {
|
||||||
|
@Field(() => [AgendaItemGql]) agenda!: AgendaItemGql[];
|
||||||
|
@Field(() => [ActivityGql]) activity!: ActivityGql[];
|
||||||
|
@Field(() => TeamWeekGql) team!: TeamWeekGql;
|
||||||
|
@Field(() => MyWeekGql) me!: MyWeekGql;
|
||||||
|
@Field(() => [HomeSprintGql]) sprints!: HomeSprintGql[];
|
||||||
|
@Field(() => [HomeProjectGql]) projects!: HomeProjectGql[];
|
||||||
|
@Field(() => [SuggestionGql]) suggestions!: SuggestionGql[];
|
||||||
|
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
|
||||||
|
@Field(() => [OnlineGql]) online!: OnlineGql[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1161,3 +1161,88 @@ export class PortfolioProjectDto {
|
|||||||
epics!: EpicDto[];
|
epics!: EpicDto[];
|
||||||
@ApiProperty() updatedAt!: string;
|
@ApiProperty() updatedAt!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AgendaItemDto {
|
||||||
|
@ApiProperty({ format: 'date' }) day!: string;
|
||||||
|
@ApiProperty({ enum: ['ISSUE', 'SPRINT_END', 'RELEASE'] }) kind!: string;
|
||||||
|
@ApiProperty() projectKey!: string;
|
||||||
|
@ApiProperty() title!: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) issueKey!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TeamWeekDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [Number],
|
||||||
|
description: 'Finished per day, last 14, oldest first',
|
||||||
|
})
|
||||||
|
perDay!: number[];
|
||||||
|
@ApiProperty() thisWeek!: number;
|
||||||
|
@ApiProperty() lastWeek!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MyWeekDto {
|
||||||
|
@ApiProperty() thisWeek!: number;
|
||||||
|
@ApiProperty() lastWeek!: number;
|
||||||
|
@ApiProperty({ description: 'Days in a row with something finished' })
|
||||||
|
streak!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeSprintDto {
|
||||||
|
@ApiProperty() projectKey!: string;
|
||||||
|
@ApiProperty() projectName!: string;
|
||||||
|
@ApiProperty({ type: SprintPulseDto }) sprint!: SprintPulseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeSprintBriefDto {
|
||||||
|
@ApiProperty() name!: string;
|
||||||
|
@ApiProperty() percent!: number;
|
||||||
|
@ApiProperty() behind!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeProjectDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||||
|
@ApiProperty() open!: number;
|
||||||
|
@ApiProperty() overdue!: number;
|
||||||
|
@ApiProperty({
|
||||||
|
type: [Number],
|
||||||
|
description: 'Activity per day, last 14, oldest first',
|
||||||
|
})
|
||||||
|
activity!: number[];
|
||||||
|
@ApiProperty({ type: HomeSprintBriefDto, nullable: true })
|
||||||
|
sprint!: HomeSprintBriefDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SuggestionDto {
|
||||||
|
@ApiProperty({ enum: ['UNASSIGNED', 'PROPOSALS', 'QA_WAITING'] })
|
||||||
|
kind!: string;
|
||||||
|
@ApiProperty() projectKey!: string;
|
||||||
|
@ApiProperty() count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FirstStepsDto {
|
||||||
|
@ApiProperty() project!: boolean;
|
||||||
|
@ApiProperty() invited!: boolean;
|
||||||
|
@ApiProperty() ticket!: boolean;
|
||||||
|
@ApiProperty() calendar!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnlineDto {
|
||||||
|
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
|
||||||
|
@ApiProperty() projectKey!: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) issueKey!: string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) issueTitle!: string | null;
|
||||||
|
@ApiProperty() editing!: boolean;
|
||||||
|
@ApiProperty() at!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeDto {
|
||||||
|
@ApiProperty({ type: [AgendaItemDto] }) agenda!: AgendaItemDto[];
|
||||||
|
@ApiProperty({ type: [ActivityDto] }) activity!: ActivityDto[];
|
||||||
|
@ApiProperty({ type: TeamWeekDto }) team!: TeamWeekDto;
|
||||||
|
@ApiProperty({ type: MyWeekDto }) me!: MyWeekDto;
|
||||||
|
@ApiProperty({ type: [HomeSprintDto] }) sprints!: HomeSprintDto[];
|
||||||
|
@ApiProperty({ type: [HomeProjectDto] }) projects!: HomeProjectDto[];
|
||||||
|
@ApiProperty({ type: [SuggestionDto] }) suggestions!: SuggestionDto[];
|
||||||
|
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
|
||||||
|
@ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
IssueDto,
|
IssueDto,
|
||||||
IssuePageDto,
|
IssuePageDto,
|
||||||
PortfolioProjectDto,
|
PortfolioProjectDto,
|
||||||
|
HomeDto,
|
||||||
PresenceDto,
|
PresenceDto,
|
||||||
} from './dto/responses.dto';
|
} from './dto/responses.dto';
|
||||||
import { viewerOf } from '../common/viewer';
|
import { viewerOf } from '../common/viewer';
|
||||||
@@ -95,6 +96,18 @@ export class IssuesController {
|
|||||||
private readonly projects: ProjectsService,
|
private readonly projects: ProjectsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get('me/home')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'The home page beyond my work',
|
||||||
|
description:
|
||||||
|
'Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: HomeDto })
|
||||||
|
@ApiReadErrors()
|
||||||
|
home(@CurrentUser() me: User) {
|
||||||
|
return this.projects.home(me.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('me/portfolio')
|
@Get('me/portfolio')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'My projects at a glance',
|
summary: 'My projects at a glance',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export * from './lib/facades/insights.facade';
|
|||||||
export * from './lib/facades/my-views.facade';
|
export * from './lib/facades/my-views.facade';
|
||||||
export * from './lib/facades/presence.facade';
|
export * from './lib/facades/presence.facade';
|
||||||
export * from './lib/facades/portfolio.facade';
|
export * from './lib/facades/portfolio.facade';
|
||||||
|
export * from './lib/facades/my-home.facade';
|
||||||
export * from './lib/facades/backlog.facade';
|
export * from './lib/facades/backlog.facade';
|
||||||
export * from './lib/facades/list.facade';
|
export * from './lib/facades/list.facade';
|
||||||
export * from './lib/facades/propose.facade';
|
export * from './lib/facades/propose.facade';
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { type } from '@ngrx/signals';
|
||||||
|
import { eventGroup } from '@ngrx/signals/events';
|
||||||
|
import { Home } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
export const myHomePageEvents = eventGroup({
|
||||||
|
source: 'My Home Page',
|
||||||
|
events: {
|
||||||
|
refreshed: type<void>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const myHomeApiEvents = eventGroup({
|
||||||
|
source: 'My Home API',
|
||||||
|
events: {
|
||||||
|
loaded: type<Home>(),
|
||||||
|
loadFailed: type<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { injectDispatch } from '@ngrx/signals/events';
|
||||||
|
import { myHomePageEvents } from '../events/my-home.events';
|
||||||
|
import { MyHomeStore } from '../stores/my-home.store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home page beyond «my work»: agenda, activity, the week, sprints,
|
||||||
|
* project cards, suggestions, first steps and who is on what now.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MyHomeFacade {
|
||||||
|
private readonly store = inject(MyHomeStore);
|
||||||
|
private readonly dispatch = injectDispatch(myHomePageEvents);
|
||||||
|
|
||||||
|
/** Null until the first load. */
|
||||||
|
readonly home = this.store.home;
|
||||||
|
readonly error = this.store.error;
|
||||||
|
|
||||||
|
/** Loads it (again, to refresh). */
|
||||||
|
refresh(): void {
|
||||||
|
this.dispatch.refreshed();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
libs/web/tracker/application/src/lib/stores/my-home.store.ts
Normal file
50
libs/web/tracker/application/src/lib/stores/my-home.store.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { mapResponse } from '@ngrx/operators';
|
||||||
|
import { signalStore, withState } from '@ngrx/signals';
|
||||||
|
import {
|
||||||
|
Events,
|
||||||
|
on,
|
||||||
|
withEventHandlers,
|
||||||
|
withReducer,
|
||||||
|
} from '@ngrx/signals/events';
|
||||||
|
import { Home, ProjectsGateway } from '@task-stream/web/tracker/domain';
|
||||||
|
import { exhaustMap } from 'rxjs';
|
||||||
|
import { myHomeApiEvents, myHomePageEvents } from '../events/my-home.events';
|
||||||
|
import { describeError } from '../notifications';
|
||||||
|
|
||||||
|
interface MyHomeState {
|
||||||
|
home: Home | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home page beyond «my work». Root-scoped, so coming back shows the
|
||||||
|
* last reading at once while it refreshes.
|
||||||
|
*/
|
||||||
|
export const MyHomeStore = signalStore(
|
||||||
|
{ providedIn: 'root' },
|
||||||
|
withState<MyHomeState>({ home: null, error: null }),
|
||||||
|
withReducer(
|
||||||
|
on(myHomeApiEvents.loaded, ({ payload }) => ({
|
||||||
|
home: payload,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
on(myHomeApiEvents.loadFailed, ({ payload }) => ({ error: payload })),
|
||||||
|
),
|
||||||
|
withEventHandlers(
|
||||||
|
(_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
|
||||||
|
// A refresh while one is under way waits for it instead of piling up.
|
||||||
|
load$: events.on(myHomePageEvents.refreshed).pipe(
|
||||||
|
exhaustMap(() =>
|
||||||
|
projects.myHome().pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: (home) => myHomeApiEvents.loaded(home),
|
||||||
|
error: (error) =>
|
||||||
|
myHomeApiEvents.loadFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -13,3 +13,4 @@ export * from './lib/models/shortcuts';
|
|||||||
export * from './lib/models/due';
|
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';
|
||||||
|
|||||||
58
libs/web/tracker/domain/src/lib/models/home.ts
Normal file
58
libs/web/tracker/domain/src/lib/models/home.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { SprintPulse } from './insights';
|
||||||
|
import { Activity, UserRef } from './models';
|
||||||
|
|
||||||
|
/** Something on a day of my coming week. */
|
||||||
|
export interface AgendaItem {
|
||||||
|
/** `YYYY-MM-DD`. */
|
||||||
|
day: string;
|
||||||
|
kind: 'ISSUE' | 'SPRINT_END' | 'RELEASE';
|
||||||
|
projectKey: string;
|
||||||
|
title: string;
|
||||||
|
issueKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a project's card shows besides its name. */
|
||||||
|
export interface HomeProject {
|
||||||
|
projectId: string;
|
||||||
|
open: number;
|
||||||
|
overdue: number;
|
||||||
|
/** Activity per day, the last 14, oldest first. */
|
||||||
|
activity: number[];
|
||||||
|
sprint: { name: string; percent: number; behind: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something worth doing when nothing is mine. */
|
||||||
|
export interface Suggestion {
|
||||||
|
kind: 'UNASSIGNED' | 'PROPOSALS' | 'QA_WAITING';
|
||||||
|
projectKey: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Someone of my projects on a ticket right now. */
|
||||||
|
export interface Online {
|
||||||
|
user: UserRef;
|
||||||
|
projectKey: string;
|
||||||
|
issueKey: string | null;
|
||||||
|
issueTitle: string | null;
|
||||||
|
editing: boolean;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The home page beyond «my work». */
|
||||||
|
export interface Home {
|
||||||
|
agenda: AgendaItem[];
|
||||||
|
/** Newest first, across my projects. */
|
||||||
|
activity: Activity[];
|
||||||
|
team: { perDay: number[]; thisWeek: number; lastWeek: number };
|
||||||
|
me: { thisWeek: number; lastWeek: number; streak: number };
|
||||||
|
sprints: { projectKey: string; projectName: string; sprint: SprintPulse }[];
|
||||||
|
projects: HomeProject[];
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
firstSteps: {
|
||||||
|
project: boolean;
|
||||||
|
invited: boolean;
|
||||||
|
ticket: boolean;
|
||||||
|
calendar: boolean;
|
||||||
|
};
|
||||||
|
online: Online[];
|
||||||
|
}
|
||||||
@@ -378,6 +378,8 @@ export interface Release {
|
|||||||
export interface Activity {
|
export interface Activity {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
/** Absent in activity read before it carried the project. */
|
||||||
|
projectId?: string;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
issueKey: string | null;
|
issueKey: string | null;
|
||||||
actor: UserRef;
|
actor: UserRef;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Home } from '../models/home';
|
||||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +70,8 @@ export abstract class ProjectsGateway {
|
|||||||
abstract stats(projectId: string): Observable<ProjectStats>;
|
abstract stats(projectId: string): Observable<ProjectStats>;
|
||||||
/** My active projects at a glance. */
|
/** My active projects at a glance. */
|
||||||
abstract portfolio(): Observable<PortfolioProject[]>;
|
abstract portfolio(): Observable<PortfolioProject[]>;
|
||||||
|
/** The home page beyond «my work». */
|
||||||
|
abstract myHome(): Observable<Home>;
|
||||||
/** The dashboard; with `since`, also what changed after it. */
|
/** The dashboard; with `since`, also what changed after it. */
|
||||||
abstract insights(
|
abstract insights(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ProjectAnalytics,
|
ProjectAnalytics,
|
||||||
ProjectInsights,
|
ProjectInsights,
|
||||||
PortfolioProject,
|
PortfolioProject,
|
||||||
|
Home,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ProjectStats,
|
ProjectStats,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
ProjectAnalyticsDocument,
|
ProjectAnalyticsDocument,
|
||||||
ProjectInsightsDocument,
|
ProjectInsightsDocument,
|
||||||
PortfolioDocument,
|
PortfolioDocument,
|
||||||
|
MyHomeDocument,
|
||||||
ProjectDocument,
|
ProjectDocument,
|
||||||
ProjectStatsDocument,
|
ProjectStatsDocument,
|
||||||
PublicProjectsDocument,
|
PublicProjectsDocument,
|
||||||
@@ -68,6 +70,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
|||||||
.pipe(map((d) => toStats(d.project.stats)));
|
.pipe(map((d) => toStats(d.project.stats)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
myHome(): Observable<Home> {
|
||||||
|
return this.gql
|
||||||
|
.query(MyHomeDocument, {})
|
||||||
|
.pipe(map((d) => d.myHome as Home));
|
||||||
|
}
|
||||||
|
|
||||||
portfolio(): Observable<PortfolioProject[]> {
|
portfolio(): Observable<PortfolioProject[]> {
|
||||||
return this.gql.query(PortfolioDocument, {}).pipe(
|
return this.gql.query(PortfolioDocument, {}).pipe(
|
||||||
map((d) =>
|
map((d) =>
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ fragment ReleaseFields on Release {
|
|||||||
fragment ActivityFields on Activity {
|
fragment ActivityFields on Activity {
|
||||||
id
|
id
|
||||||
type
|
type
|
||||||
|
projectId
|
||||||
issueId
|
issueId
|
||||||
issueKey
|
issueKey
|
||||||
summary
|
summary
|
||||||
|
|||||||
@@ -706,6 +706,7 @@ export type ReleaseFieldsFragment = {
|
|||||||
export type ActivityFieldsFragment = {
|
export type ActivityFieldsFragment = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
projectId: string;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
issueKey: string | null;
|
issueKey: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -2142,6 +2143,73 @@ export type PortfolioQuery = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MyHomeQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
|
export type MyHomeQuery = {
|
||||||
|
myHome: {
|
||||||
|
agenda: Array<{
|
||||||
|
day: string;
|
||||||
|
kind: string;
|
||||||
|
projectKey: string;
|
||||||
|
title: string;
|
||||||
|
issueKey: string | null;
|
||||||
|
}>;
|
||||||
|
activity: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
projectId: string;
|
||||||
|
issueId: string | null;
|
||||||
|
issueKey: string | null;
|
||||||
|
summary: string;
|
||||||
|
occurredAt: string;
|
||||||
|
actor: { id: string; name: string };
|
||||||
|
}>;
|
||||||
|
team: { perDay: Array<number>; thisWeek: number; lastWeek: number };
|
||||||
|
me: { thisWeek: number; lastWeek: number; streak: number };
|
||||||
|
sprints: Array<{
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
projects: Array<{
|
||||||
|
projectId: string;
|
||||||
|
open: number;
|
||||||
|
overdue: number;
|
||||||
|
activity: Array<number>;
|
||||||
|
sprint: { name: string; percent: number; behind: boolean } | null;
|
||||||
|
}>;
|
||||||
|
suggestions: Array<{ kind: string; projectKey: string; count: number }>;
|
||||||
|
firstSteps: {
|
||||||
|
project: boolean;
|
||||||
|
invited: boolean;
|
||||||
|
ticket: boolean;
|
||||||
|
calendar: boolean;
|
||||||
|
};
|
||||||
|
online: Array<{
|
||||||
|
projectKey: string;
|
||||||
|
issueKey: string | null;
|
||||||
|
issueTitle: string | null;
|
||||||
|
editing: boolean;
|
||||||
|
at: string;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ReleasesQueryVariables = Exact<{
|
export type ReleasesQueryVariables = Exact<{
|
||||||
idOrKey: string;
|
idOrKey: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -2198,6 +2266,7 @@ export type ActivityQuery = {
|
|||||||
activity: Array<{
|
activity: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
projectId: string;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
issueKey: string | null;
|
issueKey: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -2540,6 +2609,7 @@ export type ActivityAddedSubscription = {
|
|||||||
activityAdded: {
|
activityAdded: {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
projectId: string;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
issueKey: string | null;
|
issueKey: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -3600,6 +3670,7 @@ export const ActivityFieldsFragmentDoc = {
|
|||||||
selections: [
|
selections: [
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||||
@@ -12690,6 +12761,353 @@ export const PortfolioDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<PortfolioQuery, PortfolioQueryVariables>;
|
} as unknown as DocumentNode<PortfolioQuery, PortfolioQueryVariables>;
|
||||||
|
export const MyHomeDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'MyHome' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'myHome' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'agenda' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'projectKey' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issueKey' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'activity' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'ActivityFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'team' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'perDay' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'thisWeek' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'lastWeek' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'me' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'thisWeek' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'lastWeek' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'streak' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'sprints' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'projectKey' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'projectName' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'projects' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'open' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'overdue' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'activity' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'sprint' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'percent' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'behind' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'suggestions' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'kind' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'projectKey' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'firstSteps' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'project' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'invited' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'ticket' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'calendar' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'online' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'user' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'projectKey' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issueKey' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issueTitle' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'editing' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'ActivityFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Activity' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'occurredAt' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'actor' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<MyHomeQuery, MyHomeQueryVariables>;
|
||||||
export const ReleasesDocument = {
|
export const ReleasesDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -13144,6 +13562,7 @@ export const ActivityDocument = {
|
|||||||
selections: [
|
selections: [
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||||
@@ -14875,6 +15294,7 @@ export const ActivityAddedDocument = {
|
|||||||
selections: [
|
selections: [
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueKey' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'summary' } },
|
||||||
|
|||||||
@@ -351,3 +351,79 @@ query Portfolio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query MyHome {
|
||||||
|
myHome {
|
||||||
|
agenda {
|
||||||
|
day
|
||||||
|
kind
|
||||||
|
projectKey
|
||||||
|
title
|
||||||
|
issueKey
|
||||||
|
}
|
||||||
|
activity {
|
||||||
|
...ActivityFields
|
||||||
|
}
|
||||||
|
team {
|
||||||
|
perDay
|
||||||
|
thisWeek
|
||||||
|
lastWeek
|
||||||
|
}
|
||||||
|
me {
|
||||||
|
thisWeek
|
||||||
|
lastWeek
|
||||||
|
streak
|
||||||
|
}
|
||||||
|
sprints {
|
||||||
|
projectKey
|
||||||
|
projectName
|
||||||
|
sprint {
|
||||||
|
sprintId
|
||||||
|
name
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
totalDays
|
||||||
|
daysLeft
|
||||||
|
tickets
|
||||||
|
doneTickets
|
||||||
|
points
|
||||||
|
donePoints
|
||||||
|
pacePerDay
|
||||||
|
neededPerDay
|
||||||
|
behind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projects {
|
||||||
|
projectId
|
||||||
|
open
|
||||||
|
overdue
|
||||||
|
activity
|
||||||
|
sprint {
|
||||||
|
name
|
||||||
|
percent
|
||||||
|
behind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suggestions {
|
||||||
|
kind
|
||||||
|
projectKey
|
||||||
|
count
|
||||||
|
}
|
||||||
|
firstSteps {
|
||||||
|
project
|
||||||
|
invited
|
||||||
|
ticket
|
||||||
|
calendar
|
||||||
|
}
|
||||||
|
online {
|
||||||
|
user {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
|
projectKey
|
||||||
|
issueKey
|
||||||
|
issueTitle
|
||||||
|
editing
|
||||||
|
at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
} from '../ui/markdown';
|
} from '../ui/markdown';
|
||||||
import { RichEditorComponent } from '../ui/rich-editor';
|
import { RichEditorComponent } from '../ui/rich-editor';
|
||||||
import { CustomFieldComponent } from '../ui/custom-field';
|
import { CustomFieldComponent } from '../ui/custom-field';
|
||||||
|
import { rememberIssue } from '../ui/home/recent-issues';
|
||||||
|
|
||||||
const NONE = '';
|
const NONE = '';
|
||||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||||
@@ -1306,8 +1307,17 @@ export class IssuePage {
|
|||||||
constructor() {
|
constructor() {
|
||||||
effect(() => this.facade.open(this.issueKey()));
|
effect(() => this.facade.open(this.issueKey()));
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const id = this.facade.issue()?.id;
|
const issue = this.facade.issue();
|
||||||
if (id) untracked(() => this.presence.open(id));
|
if (!issue) return;
|
||||||
|
untracked(() => {
|
||||||
|
this.presence.open(issue.id);
|
||||||
|
// «Retomar» on the home page.
|
||||||
|
rememberIssue({
|
||||||
|
key: issue.key,
|
||||||
|
title: issue.title,
|
||||||
|
projectKey: issue.key.slice(0, issue.key.lastIndexOf('-')),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
|
|||||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||||
import {
|
import {
|
||||||
HomeFacade,
|
HomeFacade,
|
||||||
|
MyHomeFacade,
|
||||||
ProjectsFacade,
|
ProjectsFacade,
|
||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
Release,
|
Release,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
|
Suggestion,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
@@ -126,10 +128,20 @@ const VISIBLE = 12;
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
} @empty {
|
} @empty {
|
||||||
<p class="ts-work__empty">
|
<p class="ts-work__empty">No tienes tickets abiertos asignados.</p>
|
||||||
No tienes tickets abiertos asignados. Buen momento para revisar lo
|
@if (suggestions().length) {
|
||||||
que espera QA.
|
<ul class="ts-work__ideas">
|
||||||
</p>
|
@for (s of suggestions(); track s.kind + s.projectKey) {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
[routerLink]="suggestionLink(s)"
|
||||||
|
[queryParams]="suggestionQuery(s)"
|
||||||
|
>{{ suggestionText(s) }}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (home.mineTotal() > shown()) {
|
@if (home.mineTotal() > shown()) {
|
||||||
<p class="ts-muted ts-work__more">
|
<p class="ts-muted ts-work__more">
|
||||||
@@ -436,6 +448,17 @@ const VISIBLE = 12;
|
|||||||
color: var(--ts-muted);
|
color: var(--ts-muted);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
.ts-work__ideas {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
a {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
.ts-work__more {
|
.ts-work__more {
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -444,6 +467,31 @@ const VISIBLE = 12;
|
|||||||
})
|
})
|
||||||
export class MyWorkComponent implements OnInit {
|
export class MyWorkComponent implements OnInit {
|
||||||
protected readonly home = inject(HomeFacade);
|
protected readonly home = inject(HomeFacade);
|
||||||
|
private readonly myHome = inject(MyHomeFacade);
|
||||||
|
/** What to do when nothing is mine (from the home reading). */
|
||||||
|
protected readonly suggestions = computed(
|
||||||
|
() => this.myHome.home()?.suggestions ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
protected suggestionText(s: Suggestion): string {
|
||||||
|
const n = s.count;
|
||||||
|
switch (s.kind) {
|
||||||
|
case 'UNASSIGNED':
|
||||||
|
return `${n} ${n === 1 ? 'ticket sin dueño' : 'tickets sin dueño'} en ${s.projectKey}`;
|
||||||
|
case 'QA_WAITING':
|
||||||
|
return `${n} ${n === 1 ? 'ticket espera' : 'tickets esperan'} QA en ${s.projectKey}`;
|
||||||
|
default:
|
||||||
|
return `${n} ${n === 1 ? 'propuesta del público espera' : 'propuestas del público esperan'} respuesta en ${s.projectKey}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected suggestionLink(s: Suggestion): unknown[] {
|
||||||
|
return ['/p', s.projectKey, s.kind === 'UNASSIGNED' ? 'list' : 'board'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected suggestionQuery(s: Suggestion): Record<string, string> | null {
|
||||||
|
return s.kind === 'UNASSIGNED' ? { missing: 'assignee' } : null;
|
||||||
|
}
|
||||||
private readonly drawer = inject(DrawerService);
|
private readonly drawer = inject(DrawerService);
|
||||||
private readonly calendarPanel =
|
private readonly calendarPanel =
|
||||||
viewChild.required<TemplateRef<unknown>>('calendarPanel');
|
viewChild.required<TemplateRef<unknown>>('calendarPanel');
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
DestroyRef,
|
||||||
|
DOCUMENT,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
signal,
|
signal,
|
||||||
@@ -27,12 +29,32 @@ import {
|
|||||||
type PiSelectFieldOption,
|
type PiSelectFieldOption,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||||
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
import {
|
||||||
import { ProjectRole } from '@task-stream/web/tracker/domain';
|
HomeFacade,
|
||||||
|
InboxFacade,
|
||||||
|
MyHomeFacade,
|
||||||
|
ProjectsFacade,
|
||||||
|
} from '@task-stream/web/tracker/application';
|
||||||
|
import {
|
||||||
|
NotificationKind,
|
||||||
|
ProjectRole,
|
||||||
|
StatusCategory,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { GoToComponent } from '../ui/go-to';
|
import { GoToComponent } from '../ui/go-to';
|
||||||
import { ProjectCardComponent } from '../ui/project-card';
|
import { ProjectCardComponent } from '../ui/project-card';
|
||||||
import { MyWorkComponent } from './my-work';
|
import { MyWorkComponent } from './my-work';
|
||||||
|
import { ActivityFeedComponent } from '../ui/home/activity-feed';
|
||||||
|
import { DaySummaryComponent } from '../ui/home/day-summary';
|
||||||
|
import { FirstStepsComponent } from '../ui/home/first-steps';
|
||||||
|
import { HomeSprintsComponent } from '../ui/home/home-sprints';
|
||||||
|
import { OnlineNowComponent } from '../ui/home/online-now';
|
||||||
|
import { ResumeComponent } from '../ui/home/resume';
|
||||||
|
import { WeekAgendaComponent } from '../ui/home/week-agenda';
|
||||||
|
import { WeekStatsComponent } from '../ui/home/week-stats';
|
||||||
|
|
||||||
|
/** How often the home page refreshes its reading while open. */
|
||||||
|
const REFRESH_MS = 60_000;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-projects-page',
|
selector: 'ts-projects-page',
|
||||||
@@ -43,6 +65,14 @@ import { MyWorkComponent } from './my-work';
|
|||||||
GoToComponent,
|
GoToComponent,
|
||||||
ProjectCardComponent,
|
ProjectCardComponent,
|
||||||
MyWorkComponent,
|
MyWorkComponent,
|
||||||
|
ActivityFeedComponent,
|
||||||
|
DaySummaryComponent,
|
||||||
|
FirstStepsComponent,
|
||||||
|
HomeSprintsComponent,
|
||||||
|
OnlineNowComponent,
|
||||||
|
ResumeComponent,
|
||||||
|
WeekAgendaComponent,
|
||||||
|
WeekStatsComponent,
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiEmptyStateComponent,
|
PiEmptyStateComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
@@ -53,14 +83,13 @@ import { MyWorkComponent } from './my-work';
|
|||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<header class="ts-page-header">
|
<header class="ts-page-header">
|
||||||
<div>
|
<ts-day-summary
|
||||||
<p class="ts-eyebrow">Pireon · TaskStream</p>
|
[firstName]="firstName()"
|
||||||
<h1>
|
[dueToday]="dueToday()"
|
||||||
Hola,
|
[overdue]="overdue()"
|
||||||
<span class="ts-gradient-text">{{ firstName() }}</span>
|
[qaFailed]="home.qaFailed()"
|
||||||
</h1>
|
[mentions]="unreadMentions()"
|
||||||
<p>Lo tuyo en todos tus proyectos, al día en tiempo real.</p>
|
/>
|
||||||
</div>
|
|
||||||
<div class="ts-home-actions">
|
<div class="ts-home-actions">
|
||||||
<ts-go-to />
|
<ts-go-to />
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
@@ -71,8 +100,35 @@ import { MyWorkComponent } from './my-work';
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@if (myHome.home(); as h) {
|
||||||
|
<ts-first-steps
|
||||||
|
[steps]="h.firstSteps"
|
||||||
|
[firstKey]="facade.sorted()[0]?.key ?? null"
|
||||||
|
(createProject)="openCreate()"
|
||||||
|
/>
|
||||||
|
@if (facade.projects().length) {
|
||||||
|
<ts-week-agenda [items]="h.agenda" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (facade.projects().length) {
|
@if (facade.projects().length) {
|
||||||
<ts-my-work />
|
<ts-my-work />
|
||||||
|
@if (myHome.home(); as h) {
|
||||||
|
<div class="ts-home">
|
||||||
|
<ts-activity-feed
|
||||||
|
[activity]="h.activity"
|
||||||
|
[projectKeys]="projectKeys()"
|
||||||
|
/>
|
||||||
|
<div class="ts-home__side">
|
||||||
|
<ts-week-stats [me]="h.me" [team]="h.team" />
|
||||||
|
@if (h.sprints.length) {
|
||||||
|
<ts-home-sprints [sprints]="h.sprints" />
|
||||||
|
}
|
||||||
|
<ts-online-now [online]="h.online" />
|
||||||
|
<ts-resume />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ts-section-header">
|
<div class="ts-section-header">
|
||||||
<h2>
|
<h2>
|
||||||
Proyectos
|
Proyectos
|
||||||
@@ -104,7 +160,10 @@ import { MyWorkComponent } from './my-work';
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="ts-projects">
|
<div class="ts-projects">
|
||||||
@for (project of facade.sorted(); track project.id) {
|
@for (project of facade.sorted(); track project.id) {
|
||||||
<ts-project-card [project]="project" />
|
<ts-project-card
|
||||||
|
[project]="project"
|
||||||
|
[stats]="statsOf().get(project.id) ?? null"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -185,6 +244,24 @@ import { MyWorkComponent } from './my-work';
|
|||||||
color: var(--ts-muted);
|
color: var(--ts-muted);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
ts-first-steps,
|
||||||
|
ts-week-agenda {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.ts-home {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
margin: 1.25rem 0 2rem;
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-home__side {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
.ts-public-check {
|
.ts-public-check {
|
||||||
p {
|
p {
|
||||||
/* Under the checkbox's text, past the box. */
|
/* Under the checkbox's text, past the box. */
|
||||||
@@ -248,8 +325,63 @@ export class ProjectsPage implements OnInit {
|
|||||||
|
|
||||||
protected readonly pad = (n: number) => String(n).padStart(2, '0');
|
protected readonly pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/* The home reading: agenda, activity, the week… refreshed while open. */
|
||||||
|
protected readonly myHome = inject(MyHomeFacade);
|
||||||
|
protected readonly home = inject(HomeFacade);
|
||||||
|
private readonly inbox = inject(InboxFacade);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
private readonly todayStr = new Date().toISOString().slice(0, 10);
|
||||||
|
private readonly openMine = computed(() =>
|
||||||
|
this.home
|
||||||
|
.mine()
|
||||||
|
.filter(
|
||||||
|
(i) =>
|
||||||
|
i.statusCategory !== StatusCategory.DONE &&
|
||||||
|
i.statusCategory !== StatusCategory.CANCELLED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
protected readonly dueToday = computed(
|
||||||
|
() =>
|
||||||
|
this.openMine().filter((i) => i.dueDate?.slice(0, 10) === this.todayStr)
|
||||||
|
.length,
|
||||||
|
);
|
||||||
|
protected readonly overdue = computed(
|
||||||
|
() =>
|
||||||
|
this.openMine().filter(
|
||||||
|
(i) => !!i.dueDate && i.dueDate.slice(0, 10) < this.todayStr,
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
protected readonly unreadMentions = computed(
|
||||||
|
() =>
|
||||||
|
this.inbox
|
||||||
|
.items()
|
||||||
|
.filter((n) => n.kind === NotificationKind.MENTIONED && !n.readAt)
|
||||||
|
.length,
|
||||||
|
);
|
||||||
|
protected readonly projectKeys = computed(
|
||||||
|
() => new Map(this.facade.projects().map((p) => [p.id, p.key])),
|
||||||
|
);
|
||||||
|
protected readonly statsOf = computed(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
(this.myHome.home()?.projects ?? []).map((p) => [p.projectId, p]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.facade.load();
|
this.facade.load();
|
||||||
|
this.myHome.refresh();
|
||||||
|
const timer = setInterval(() => this.myHome.refresh(), REFRESH_MS);
|
||||||
|
const onVisible = () => {
|
||||||
|
if (this.document.visibilityState === 'visible') this.myHome.refresh();
|
||||||
|
};
|
||||||
|
this.document.addEventListener('visibilitychange', onVisible);
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
clearInterval(timer);
|
||||||
|
this.document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected openCreate(): void {
|
protected openCreate(): void {
|
||||||
|
|||||||
150
libs/web/tracker/presenters/src/lib/ui/home/activity-feed.ts
Normal file
150
libs/web/tracker/presenters/src/lib/ui/home/activity-feed.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { Activity } from '@task-stream/web/tracker/domain';
|
||||||
|
import { initials, projectTone, timeAgo } from '../copy';
|
||||||
|
|
||||||
|
/** What moved lately in all my projects. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-activity-feed',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
role: 'region',
|
||||||
|
'aria-labelledby': 'feed-title',
|
||||||
|
},
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="feed-title">Lo último en tus proyectos</h2>
|
||||||
|
</header>
|
||||||
|
@if (activity().length) {
|
||||||
|
<ol class="ts-feed">
|
||||||
|
@for (entry of shown(); track entry.id) {
|
||||||
|
<li>
|
||||||
|
<span class="ts-feed__who" aria-hidden="true">{{
|
||||||
|
initialsOf(entry.actor.name)
|
||||||
|
}}</span>
|
||||||
|
<span class="ts-feed__what">
|
||||||
|
@if (entry.issueKey) {
|
||||||
|
<a
|
||||||
|
[routerLink]="['/p', keyOf(entry), 'issues', entry.issueKey]"
|
||||||
|
>{{ entry.summary }}</a
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
|
{{ entry.summary }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ts-feed__project"
|
||||||
|
[style.--tone]="toneOf(keyOf(entry))"
|
||||||
|
>{{ keyOf(entry) }}</span
|
||||||
|
>
|
||||||
|
<span class="ts-feed__when">{{ ago(entry.occurredAt) }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
@if (activity().length > SHORT) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-plain-link ts-feed__more"
|
||||||
|
(click)="all.set(!all())"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
all()
|
||||||
|
? 'Ver menos'
|
||||||
|
: 'Ver más (' + (activity().length - SHORT) + ')'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<p class="ts-empty">Aún no ha pasado nada en tus proyectos.</p>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-feed {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.75rem minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas: 'who what project' 'who when when';
|
||||||
|
column-gap: 0.625rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-feed__who {
|
||||||
|
grid-area: who;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||||
|
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ts-feed__what {
|
||||||
|
grid-area: what;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ts-feed__project {
|
||||||
|
grid-area: project;
|
||||||
|
color: var(--tone);
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ts-feed__more {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.ts-feed__when {
|
||||||
|
grid-area: when;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ActivityFeedComponent {
|
||||||
|
readonly activity = input.required<Activity[]>();
|
||||||
|
/** Project id → key. */
|
||||||
|
readonly projectKeys = input.required<Map<string, string>>();
|
||||||
|
|
||||||
|
protected readonly SHORT = 10;
|
||||||
|
protected readonly all = signal(false);
|
||||||
|
protected readonly shown = computed(() =>
|
||||||
|
this.all() ? this.activity() : this.activity().slice(0, this.SHORT),
|
||||||
|
);
|
||||||
|
protected readonly initialsOf = initials;
|
||||||
|
protected readonly toneOf = projectTone;
|
||||||
|
protected readonly ago = (iso: string) => timeAgo(iso);
|
||||||
|
|
||||||
|
protected keyOf(entry: Activity): string {
|
||||||
|
return (
|
||||||
|
this.projectKeys().get(entry.projectId ?? '') ??
|
||||||
|
entry.issueKey?.slice(0, entry.issueKey.lastIndexOf('-')) ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
libs/web/tracker/presenters/src/lib/ui/home/day-summary.ts
Normal file
86
libs/web/tracker/presenters/src/lib/ui/home/day-summary.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
const plural = (n: number, one: string, many: string) =>
|
||||||
|
`${n} ${n === 1 ? one : many}`;
|
||||||
|
|
||||||
|
/** «Buenos días, Ana», today's date and what today holds, in one sentence. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-day-summary',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<p class="ts-eyebrow">{{ today() }}</p>
|
||||||
|
<h1>
|
||||||
|
{{ greeting() }},
|
||||||
|
<span class="ts-gradient-text">{{ firstName() }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="ts-day">{{ summary() }}</p>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ts-day {
|
||||||
|
margin: 0.375rem 0 0;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DaySummaryComponent {
|
||||||
|
readonly firstName = input('');
|
||||||
|
readonly dueToday = input(0);
|
||||||
|
readonly overdue = input(0);
|
||||||
|
readonly qaFailed = input(0);
|
||||||
|
readonly mentions = input(0);
|
||||||
|
/** Injected in tests; the clock otherwise. */
|
||||||
|
readonly now = input<Date>(new Date());
|
||||||
|
|
||||||
|
protected readonly greeting = computed(() => {
|
||||||
|
const hour = this.now().getHours();
|
||||||
|
return hour < 6
|
||||||
|
? 'Buenas noches'
|
||||||
|
: hour < 14
|
||||||
|
? 'Buenos días'
|
||||||
|
: hour < 21
|
||||||
|
? 'Buenas tardes'
|
||||||
|
: 'Buenas noches';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly today = computed(() =>
|
||||||
|
this.now().toLocaleDateString('es', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly summary = computed(() => {
|
||||||
|
const parts = [
|
||||||
|
this.dueToday()
|
||||||
|
? `hoy vence ${this.dueToday() === 1 ? '1 ticket tuyo' : `${this.dueToday()} tickets tuyos`}`
|
||||||
|
: '',
|
||||||
|
this.overdue()
|
||||||
|
? plural(this.overdue(), 'ticket vencido', 'tickets vencidos')
|
||||||
|
: '',
|
||||||
|
this.qaFailed()
|
||||||
|
? plural(this.qaFailed(), 'vuelto de QA', 'vueltos de QA')
|
||||||
|
: '',
|
||||||
|
this.mentions()
|
||||||
|
? plural(this.mentions(), 'mención sin leer', 'menciones sin leer')
|
||||||
|
: '',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (!parts.length) return 'Nada urgente hoy: buen momento para avanzar.';
|
||||||
|
const text =
|
||||||
|
parts.length > 1
|
||||||
|
? `${parts.slice(0, -1).join(', ')} y ${parts[parts.length - 1]}`
|
||||||
|
: parts[0];
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1) + '.';
|
||||||
|
});
|
||||||
|
}
|
||||||
149
libs/web/tracker/presenters/src/lib/ui/home/first-steps.ts
Normal file
149
libs/web/tracker/presenters/src/lib/ui/home/first-steps.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { Home } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
const HIDDEN = 'task-stream.firstSteps.hidden';
|
||||||
|
|
||||||
|
/** The first things to do in TaskStream; gone once done (or dismissed). */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-first-steps',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: { class: 'ts-panel', role: 'region', 'aria-labelledby': 'steps-title' },
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
@if (shown()) {
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="steps-title">Primeros pasos</h2>
|
||||||
|
<button type="button" class="ts-plain-link" (click)="dismiss()">
|
||||||
|
Ocultar
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<p class="ts-steps__progress">
|
||||||
|
{{ doneCount() }} de 4 hechos
|
||||||
|
<span class="ts-bar"
|
||||||
|
><span [style.width.%]="(doneCount() / 4) * 100"></span
|
||||||
|
></span>
|
||||||
|
</p>
|
||||||
|
<ol class="ts-steps">
|
||||||
|
<li [class.is-done]="steps().project">
|
||||||
|
@if (steps().project) {
|
||||||
|
Crear un proyecto
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-plain-link"
|
||||||
|
(click)="createProject.emit()"
|
||||||
|
>
|
||||||
|
Crear un proyecto
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
<li [class.is-done]="steps().invited">
|
||||||
|
@if (firstKey(); as key) {
|
||||||
|
<a [routerLink]="['/p', key, 'members']">Invitar a alguien</a>
|
||||||
|
} @else {
|
||||||
|
Invitar a alguien
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
<li [class.is-done]="steps().ticket">
|
||||||
|
@if (firstKey(); as key) {
|
||||||
|
<a [routerLink]="['/p', key, 'board']">Crear tu primer ticket</a>
|
||||||
|
} @else {
|
||||||
|
Crear tu primer ticket
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
<li [class.is-done]="steps().calendar">
|
||||||
|
Conectar tu calendario (botón «Calendario», en Mis tickets)
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host:has(.ts-steps) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
:host:not(:has(.ts-steps)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ts-steps__progress {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.ts-steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
border: 1.5px solid var(--ts-line-strong, var(--ts-muted));
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
&.is-done {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
&::before {
|
||||||
|
border-color: var(--ts-c-green, #2e9e6a);
|
||||||
|
background: var(--ts-c-green, #2e9e6a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class FirstStepsComponent {
|
||||||
|
readonly steps = input.required<Home['firstSteps']>();
|
||||||
|
/** A project to point the links at, if any. */
|
||||||
|
readonly firstKey = input<string | null>(null);
|
||||||
|
readonly createProject = output<void>();
|
||||||
|
|
||||||
|
private readonly hidden = signal(readHidden());
|
||||||
|
protected readonly doneCount = computed(
|
||||||
|
() => Object.values(this.steps()).filter(Boolean).length,
|
||||||
|
);
|
||||||
|
protected readonly shown = computed(
|
||||||
|
() => !this.hidden() && this.doneCount() < 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
protected dismiss(): void {
|
||||||
|
this.hidden.set(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HIDDEN, '1');
|
||||||
|
} catch {
|
||||||
|
/* a convenience */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHidden(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(HIDDEN) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
libs/web/tracker/presenters/src/lib/ui/home/home-panel.scss
Normal file
94
libs/web/tracker/presenters/src/lib/ui/home/home-panel.scss
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Shared by the dashboard's insight panels (each host is a .ts-panel).
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-muted {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-issues {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-key {
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-days {
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--ts-sunken);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--tone, var(--ts-brand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-warn {
|
||||||
|
color: var(--ts-c-red, #d14343);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
71
libs/web/tracker/presenters/src/lib/ui/home/home-sprints.ts
Normal file
71
libs/web/tracker/presenters/src/lib/ui/home/home-sprints.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { Home } from '@task-stream/web/tracker/domain';
|
||||||
|
import { projectTone } from '../copy';
|
||||||
|
|
||||||
|
/** The sprints under way in my projects: how far, and whether they make it. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-home-sprints',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
role: 'region',
|
||||||
|
'aria-labelledby': 'sprints-title',
|
||||||
|
},
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="sprints-title">Sprints en curso</h2>
|
||||||
|
</header>
|
||||||
|
<ul class="ts-issues ts-hsprints">
|
||||||
|
@for (row of sprints(); track row.sprint.sprintId) {
|
||||||
|
<li [style.--tone]="toneOf(row.projectKey)">
|
||||||
|
<a class="ts-key" [routerLink]="['/p', row.projectKey, 'sprints']">{{
|
||||||
|
row.projectKey
|
||||||
|
}}</a>
|
||||||
|
<a
|
||||||
|
class="ts-title"
|
||||||
|
[routerLink]="['/p', row.projectKey, 'sprints']"
|
||||||
|
>{{ row.sprint.name }}</a
|
||||||
|
>
|
||||||
|
<span class="ts-days" [class.ts-warn]="row.sprint.behind">{{
|
||||||
|
row.sprint.behind ? 'no llega' : left(row.sprint.daysLeft)
|
||||||
|
}}</span>
|
||||||
|
<span class="ts-bar"
|
||||||
|
><span [style.width.%]="percent(row.sprint)"></span
|
||||||
|
></span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-hsprints {
|
||||||
|
gap: 0.75rem;
|
||||||
|
li {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.ts-bar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class HomeSprintsComponent {
|
||||||
|
readonly sprints = input.required<Home['sprints']>();
|
||||||
|
protected readonly toneOf = projectTone;
|
||||||
|
|
||||||
|
protected left(days: number): string {
|
||||||
|
return days < 0
|
||||||
|
? 'terminado'
|
||||||
|
: days === 0
|
||||||
|
? 'último día'
|
||||||
|
: `quedan ${days} d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected percent(s: Home['sprints'][number]['sprint']): number {
|
||||||
|
if (s.points) return Math.round((s.donePoints / s.points) * 100);
|
||||||
|
return s.tickets ? Math.round((s.doneTickets / s.tickets) * 100) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
libs/web/tracker/presenters/src/lib/ui/home/online-now.ts
Normal file
94
libs/web/tracker/presenters/src/lib/ui/home/online-now.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { Online } from '@task-stream/web/tracker/domain';
|
||||||
|
import { initials } from '../copy';
|
||||||
|
|
||||||
|
/** Who of my projects is on a ticket right now, and which. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-online-now',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
role: 'region',
|
||||||
|
'aria-labelledby': 'online-title',
|
||||||
|
},
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="online-title">Ahora mismo</h2>
|
||||||
|
<span class="ts-muted">{{ online().length }} conectados</span>
|
||||||
|
</header>
|
||||||
|
@if (online().length) {
|
||||||
|
<ul class="ts-online">
|
||||||
|
@for (o of online(); track o.user.id) {
|
||||||
|
<li>
|
||||||
|
<span class="ts-online__who" aria-hidden="true"
|
||||||
|
>{{ initialsOf(o.user.name) }}<i></i
|
||||||
|
></span>
|
||||||
|
<span class="ts-online__text">
|
||||||
|
<strong>{{ o.user.name }}</strong>
|
||||||
|
{{ o.editing ? 'edita' : 'mira' }}
|
||||||
|
@if (o.issueKey) {
|
||||||
|
<a [routerLink]="['/p', o.projectKey, 'issues', o.issueKey]">{{
|
||||||
|
o.issueKey
|
||||||
|
}}</a>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="ts-empty">
|
||||||
|
Nadie más de tus proyectos está en un ticket ahora.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-online {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-online__who {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||||
|
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
i {
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border: 2px solid var(--ts-surface);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ts-c-green, #2e9e6a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class OnlineNowComponent {
|
||||||
|
readonly online = input.required<Online[]>();
|
||||||
|
protected readonly initialsOf = initials;
|
||||||
|
}
|
||||||
37
libs/web/tracker/presenters/src/lib/ui/home/recent-issues.ts
Normal file
37
libs/web/tracker/presenters/src/lib/ui/home/recent-issues.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* The tickets I opened lately, kept in this browser only: a convenience
|
||||||
|
* («Retomar»), so storage may fail or be empty.
|
||||||
|
*/
|
||||||
|
const KEY = 'task-stream.recentIssues';
|
||||||
|
const MAX = 8;
|
||||||
|
|
||||||
|
export interface RecentIssue {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
projectKey: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentIssues(): RecentIssue[] {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(KEY) ?? '[]');
|
||||||
|
return Array.isArray(raw) ? (raw as RecentIssue[]).slice(0, MAX) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This one first; the oldest drop off. */
|
||||||
|
export function rememberIssue(issue: Omit<RecentIssue, 'at'>): void {
|
||||||
|
try {
|
||||||
|
const rest = recentIssues().filter((i) => i.key !== issue.key);
|
||||||
|
localStorage.setItem(
|
||||||
|
KEY,
|
||||||
|
JSON.stringify(
|
||||||
|
[{ ...issue, at: new Date().toISOString() }, ...rest].slice(0, MAX),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* a convenience: nothing to do */
|
||||||
|
}
|
||||||
|
}
|
||||||
49
libs/web/tracker/presenters/src/lib/ui/home/resume.ts
Normal file
49
libs/web/tracker/presenters/src/lib/ui/home/resume.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { timeAgo } from '../copy';
|
||||||
|
import { recentIssues } from './recent-issues';
|
||||||
|
|
||||||
|
/** The tickets I opened lately, to pick up where I left off. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-resume',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
role: 'region',
|
||||||
|
'aria-labelledby': 'resume-title',
|
||||||
|
},
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="resume-title">Retomar</h2>
|
||||||
|
</header>
|
||||||
|
@if (items().length) {
|
||||||
|
<ul class="ts-issues">
|
||||||
|
@for (item of items(); track item.key) {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="ts-key"
|
||||||
|
[routerLink]="['/p', item.projectKey, 'issues', item.key]"
|
||||||
|
>{{ item.key }}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="ts-title"
|
||||||
|
[routerLink]="['/p', item.projectKey, 'issues', item.key]"
|
||||||
|
>{{ item.title }}</a
|
||||||
|
>
|
||||||
|
<span class="ts-days">{{ ago(item.at) }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="ts-empty">
|
||||||
|
Los tickets que abras saldrán aquí para volver a ellos.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ResumeComponent {
|
||||||
|
protected readonly items = signal(recentIssues().slice(0, 5));
|
||||||
|
protected readonly ago = (iso: string) => timeAgo(iso);
|
||||||
|
}
|
||||||
172
libs/web/tracker/presenters/src/lib/ui/home/week-agenda.ts
Normal file
172
libs/web/tracker/presenters/src/lib/ui/home/week-agenda.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AgendaItem } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const KIND: Record<AgendaItem['kind'], string> = {
|
||||||
|
ISSUE: 'vence',
|
||||||
|
SPRINT_END: 'fin de sprint',
|
||||||
|
RELEASE: 'release',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** My coming week: what is due, which sprints end, which releases land. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-week-agenda',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
role: 'region',
|
||||||
|
'aria-labelledby': 'agenda-title',
|
||||||
|
},
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="agenda-title">Tu semana</h2>
|
||||||
|
<span class="ts-muted">{{ count() }} cosas en los próximos 7 días</span>
|
||||||
|
</header>
|
||||||
|
<ol class="ts-agenda">
|
||||||
|
@for (day of days(); track day.date) {
|
||||||
|
<li [class.is-today]="day.today">
|
||||||
|
<p class="ts-agenda__day">
|
||||||
|
<span>{{ day.weekday }}</span> <strong>{{ day.number }}</strong>
|
||||||
|
</p>
|
||||||
|
@for (item of day.items; track $index) {
|
||||||
|
<a
|
||||||
|
class="ts-agenda__item"
|
||||||
|
[attr.data-kind]="item.kind"
|
||||||
|
[routerLink]="link(item)"
|
||||||
|
[attr.title]="item.projectKey + ' · ' + item.title"
|
||||||
|
>
|
||||||
|
<span class="ts-agenda__kind">{{ kindOf(item) }}</span>
|
||||||
|
{{ item.issueKey ?? item.projectKey }}
|
||||||
|
<span class="ts-agenda__title">{{ item.title }}</span>
|
||||||
|
</a>
|
||||||
|
} @empty {
|
||||||
|
<span class="ts-agenda__none">—</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-agenda {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-height: 6rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--ts-sunken);
|
||||||
|
}
|
||||||
|
li.is-today {
|
||||||
|
outline: 2px solid var(--ts-brand);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-agenda__day {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
strong {
|
||||||
|
color: var(--ts-ink);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-agenda__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.0625rem;
|
||||||
|
padding: 0.25rem 0.375rem;
|
||||||
|
border-left: 3px solid var(--ts-brand);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--ts-surface);
|
||||||
|
color: inherit;
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
&[data-kind='SPRINT_END'] {
|
||||||
|
border-left-color: var(--ts-c-amber, #c98a12);
|
||||||
|
}
|
||||||
|
&[data-kind='RELEASE'] {
|
||||||
|
border-left-color: var(--ts-c-green, #2e9e6a);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: var(--pi-color-primary-25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-agenda__kind {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ts-agenda__title {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--ts-font-body, inherit);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ts-agenda__none {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ts-agenda {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
li {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WeekAgendaComponent {
|
||||||
|
readonly items = input.required<AgendaItem[]>();
|
||||||
|
readonly now = input<Date>(new Date());
|
||||||
|
|
||||||
|
protected readonly count = computed(() => this.items().length);
|
||||||
|
protected readonly days = computed(() => {
|
||||||
|
const now = this.now();
|
||||||
|
const start = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
return Array.from({ length: 7 }, (_, n) => {
|
||||||
|
const time = start + n * DAY;
|
||||||
|
const date = new Date(time).toISOString().slice(0, 10);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
today: n === 0,
|
||||||
|
weekday: new Date(time).toLocaleDateString('es', {
|
||||||
|
weekday: 'short',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
}),
|
||||||
|
number: new Date(time).getUTCDate(),
|
||||||
|
items: this.items().filter((i) => i.day === date),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
protected kindOf(item: AgendaItem): string {
|
||||||
|
return KIND[item.kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected link(item: AgendaItem): unknown[] {
|
||||||
|
return item.issueKey
|
||||||
|
? ['/p', item.projectKey, 'issues', item.issueKey]
|
||||||
|
: [
|
||||||
|
'/p',
|
||||||
|
item.projectKey,
|
||||||
|
item.kind === 'RELEASE' ? 'releases' : 'sprints',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
142
libs/web/tracker/presenters/src/lib/ui/home/week-stats.ts
Normal file
142
libs/web/tracker/presenters/src/lib/ui/home/week-stats.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Home } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
/** «+30 %» or «−10 %» against the week before (nothing when there was none). */
|
||||||
|
function change(now: number, before: number): string {
|
||||||
|
if (!before) return now ? 'nuevo' : '';
|
||||||
|
const pct = Math.round(((now - before) / before) * 100);
|
||||||
|
return `${pct >= 0 ? '+' : '−'}${Math.abs(pct)} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the week goes: mine (and my streak) and my projects' teams. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-week-stats',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
class: 'ts-panel',
|
||||||
|
role: 'region',
|
||||||
|
'aria-labelledby': 'stats-title',
|
||||||
|
},
|
||||||
|
styleUrl: './home-panel.scss',
|
||||||
|
template: `
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="stats-title">La semana</h2>
|
||||||
|
<span class="ts-muted">Tickets terminados, últimos 7 días</span>
|
||||||
|
</header>
|
||||||
|
<div class="ts-week">
|
||||||
|
<div>
|
||||||
|
<p class="ts-week__label">Tú</p>
|
||||||
|
<p class="ts-week__value">
|
||||||
|
{{ me().thisWeek }}
|
||||||
|
@if (myChange(); as c) {
|
||||||
|
<span class="ts-week__change" [class.is-down]="c.startsWith('−')">{{
|
||||||
|
c
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="ts-week__sub">
|
||||||
|
@if (me().streak > 1) {
|
||||||
|
{{ me().streak }} días seguidos terminando algo
|
||||||
|
} @else if (me().streak === 1) {
|
||||||
|
Terminaste algo hoy o ayer
|
||||||
|
} @else {
|
||||||
|
Sin racha: hoy puede empezar una
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="ts-week__label">Tus equipos</p>
|
||||||
|
<p class="ts-week__value">
|
||||||
|
{{ team().thisWeek }}
|
||||||
|
@if (teamChange(); as c) {
|
||||||
|
<span class="ts-week__change" [class.is-down]="c.startsWith('−')">{{
|
||||||
|
c
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="ts-week__bars"
|
||||||
|
role="img"
|
||||||
|
[attr.aria-label]="'Terminados por día: ' + team().perDay.join(', ')"
|
||||||
|
>
|
||||||
|
@for (n of team().perDay; track $index) {
|
||||||
|
<span
|
||||||
|
[style.height.%]="height(n)"
|
||||||
|
[class.is-this-week]="$index >= 7"
|
||||||
|
[attr.title]="n + ' terminados'"
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-week {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.ts-week__label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.ts-week__value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.125rem 0 0.25rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ts-week__change {
|
||||||
|
color: var(--ts-c-green, #2e9e6a);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
&.is-down {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-week__sub {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.ts-week__bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 2.5rem;
|
||||||
|
span {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 2px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: color-mix(in srgb, var(--ts-brand) 35%, transparent);
|
||||||
|
&.is-this-week {
|
||||||
|
background: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WeekStatsComponent {
|
||||||
|
readonly me = input.required<Home['me']>();
|
||||||
|
readonly team = input.required<Home['team']>();
|
||||||
|
|
||||||
|
protected readonly myChange = computed(() =>
|
||||||
|
change(this.me().thisWeek, this.me().lastWeek),
|
||||||
|
);
|
||||||
|
protected readonly teamChange = computed(() =>
|
||||||
|
change(this.team().thisWeek, this.team().lastWeek),
|
||||||
|
);
|
||||||
|
private readonly max = computed(() => Math.max(1, ...this.team().perDay));
|
||||||
|
|
||||||
|
protected height(n: number): number {
|
||||||
|
return (n / this.max()) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
input,
|
input,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { Project } from '@task-stream/web/tracker/domain';
|
import { HomeProject, Project } from '@task-stream/web/tracker/domain';
|
||||||
import { AvatarsComponent } from './avatars';
|
import { AvatarsComponent } from './avatars';
|
||||||
import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
||||||
|
|
||||||
@@ -36,6 +36,33 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
|||||||
@if (project().description) {
|
@if (project().description) {
|
||||||
<p class="ts-project__description">{{ project().description }}</p>
|
<p class="ts-project__description">{{ project().description }}</p>
|
||||||
}
|
}
|
||||||
|
@if (stats(); as s) {
|
||||||
|
<div class="ts-project__stats">
|
||||||
|
<svg
|
||||||
|
class="ts-project__spark"
|
||||||
|
viewBox="0 0 100 24"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
[attr.aria-label]="'Actividad de 14 días: ' + s.activity.join(', ')"
|
||||||
|
>
|
||||||
|
<polyline [attr.points]="spark()" />
|
||||||
|
</svg>
|
||||||
|
<span class="ts-mono"
|
||||||
|
>{{ s.open }} abiertos
|
||||||
|
@if (s.overdue) {
|
||||||
|
· <span class="ts-project__late">{{ s.overdue }} vencidos</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
@if (s.sprint; as sprint) {
|
||||||
|
<span class="ts-project__sprint">
|
||||||
|
{{ sprint.name }} · {{ sprint.percent }}%
|
||||||
|
@if (sprint.behind) {
|
||||||
|
<span class="ts-project__late">· no llega</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ts-project__bottom">
|
<div class="ts-project__bottom">
|
||||||
<ts-avatars [names]="memberNames()" />
|
<ts-avatars [names]="memberNames()" />
|
||||||
<span class="ts-project__count ts-mono"
|
<span class="ts-project__count ts-mono"
|
||||||
@@ -73,6 +100,26 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
|||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
color: var(--tone);
|
color: var(--tone);
|
||||||
}
|
}
|
||||||
|
.ts-project__stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.ts-project__spark {
|
||||||
|
width: 100%;
|
||||||
|
height: 1.75rem;
|
||||||
|
polyline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--tone);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-project__late {
|
||||||
|
color: var(--ts-c-red, #d14343);
|
||||||
|
}
|
||||||
.ts-project__updated {
|
.ts-project__updated {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -120,6 +167,19 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
|||||||
})
|
})
|
||||||
export class ProjectCardComponent {
|
export class ProjectCardComponent {
|
||||||
readonly project = input.required<Project>();
|
readonly project = input.required<Project>();
|
||||||
|
/** Open work, activity and sprint (projects I see whole). */
|
||||||
|
readonly stats = input<HomeProject | null>(null);
|
||||||
|
/** 14 days of activity as a line, scaled to its busiest day. */
|
||||||
|
protected readonly spark = computed(() => {
|
||||||
|
const days = this.stats()?.activity ?? [];
|
||||||
|
const max = Math.max(1, ...days);
|
||||||
|
return days
|
||||||
|
.map(
|
||||||
|
(n, i) =>
|
||||||
|
`${(i / Math.max(1, days.length - 1)) * 100},${22 - (n / max) * 20}`,
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
});
|
||||||
protected readonly tone = computed(() => projectTone(this.project().key));
|
protected readonly tone = computed(() => projectTone(this.project().key));
|
||||||
protected readonly memberNames = computed(() =>
|
protected readonly memberNames = computed(() =>
|
||||||
this.project().members.map((m) => m.user.name),
|
this.project().members.map((m) => m.user.name),
|
||||||
|
|||||||
Reference in New Issue
Block a user