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:
2026-10-15 19:20:41 +02:00
parent affce615a7
commit 0946e9517d
57 changed files with 3728 additions and 53 deletions

View File

@@ -1904,4 +1904,71 @@ describe('task-stream (e2e)', () => {
(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);
});
});

View File

@@ -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": {
"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.",
@@ -13490,6 +13561,300 @@
"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": {
"type": "object",
"properties": {

View File

@@ -18,6 +18,18 @@ input AddChecklistItemInput {
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 {
issues: [InsightIssue!]!
@@ -507,6 +519,13 @@ type FieldBreakdown {
none: Int!
}
type FirstSteps {
calendar: Boolean!
invited: Boolean!
project: Boolean!
ticket: Boolean!
}
type FlowSeries {
counts: [Int!]!
status: String!
@@ -536,6 +555,40 @@ type GitRepository {
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"""
type InsightIssue {
assignee: UserRef
@@ -1035,6 +1088,14 @@ input MyViewCriteriaInput {
watching: Boolean
}
type MyWeek {
lastWeek: Int!
"""Days in a row with something finished"""
streak: Int!
thisWeek: Int!
}
type Notification {
actor: UserRef!
createdAt: String!
@@ -1077,6 +1138,16 @@ type NotificationSettings {
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 {
count: Int!
value: String!
@@ -1348,6 +1419,11 @@ type Query {
"""My calendar URL, if I made one"""
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])
"""
@@ -1712,6 +1788,14 @@ type Subscription {
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"""
type Team {
archived: Boolean!
@@ -1738,6 +1822,14 @@ type TeamLoad {
user: UserRef!
}
type TeamWeek {
lastWeek: Int!
"""Finished per day, last 14, oldest first"""
perDay: [Int!]!
thisWeek: Int!
}
type Throughput {
count: Int!
user: UserRef