diff --git a/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts b/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts index 36578f399b..abaffd7729 100644 --- a/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts +++ b/src/lib/features/personal-dashboard/fake-personal-dashboard-read-model.ts @@ -8,6 +8,13 @@ import type { export class FakePersonalDashboardReadModel implements IPersonalDashboardReadModel { + async getLatestHealthScores( + project: string, + count: number, + ): Promise { + return []; + } + async getPersonalFeatures(userId: number): Promise { return []; } diff --git a/src/lib/features/personal-dashboard/personal-dashboard-controller.e2e.test.ts b/src/lib/features/personal-dashboard/personal-dashboard-controller.e2e.test.ts index acc6214afa..55d5b8f892 100644 --- a/src/lib/features/personal-dashboard/personal-dashboard-controller.e2e.test.ts +++ b/src/lib/features/personal-dashboard/personal-dashboard-controller.e2e.test.ts @@ -227,6 +227,10 @@ test('should return personal dashboard project details', async () => { ); expect(body).toMatchObject({ + insights: { + avgHealthCurrentWindow: null, + avgHealthPastWindow: null, + }, owners: [{}], roles: [{}], onboardingStatus: { @@ -260,6 +264,41 @@ test('should return personal dashboard project details', async () => { }, ], }); + + const insertHealthScore = (id: string, health: number) => { + const irrelevantFlagTrendDetails = { + total_flags: 10, + stale_flags: 10, + potentially_stale_flags: 10, + }; + return db.rawDatabase('flag_trends').insert({ + ...irrelevantFlagTrendDetails, + id, + project: project.id, + health, + }); + }; + + await insertHealthScore('2024-01', 80); + await insertHealthScore('2024-02', 80); + await insertHealthScore('2024-03', 80); + await insertHealthScore('2024-04', 81); + + await insertHealthScore('2024-05', 90); + await insertHealthScore('2024-06', 91); + await insertHealthScore('2024-07', 91); + await insertHealthScore('2024-08', 91); + + const { body: bodyWithHealthScores } = await app.request.get( + `/api/admin/personal-dashboard/${project.id}`, + ); + + expect(bodyWithHealthScores).toMatchObject({ + insights: { + avgHealthPastWindow: 80, + avgHealthCurrentWindow: 91, + }, + }); }); test('should return Unleash admins', async () => { diff --git a/src/lib/features/personal-dashboard/personal-dashboard-read-model-type.ts b/src/lib/features/personal-dashboard/personal-dashboard-read-model-type.ts index 6926957bcb..5fbbad90a0 100644 --- a/src/lib/features/personal-dashboard/personal-dashboard-read-model-type.ts +++ b/src/lib/features/personal-dashboard/personal-dashboard-read-model-type.ts @@ -21,4 +21,5 @@ export type PersonalProject = BasePersonalProject & { export interface IPersonalDashboardReadModel { getPersonalFeatures(userId: number): Promise; getPersonalProjects(userId: number): Promise; + getLatestHealthScores(project: string, count: number): Promise; } diff --git a/src/lib/features/personal-dashboard/personal-dashboard-read-model.ts b/src/lib/features/personal-dashboard/personal-dashboard-read-model.ts index 87e4df16d9..1dec47bca8 100644 --- a/src/lib/features/personal-dashboard/personal-dashboard-read-model.ts +++ b/src/lib/features/personal-dashboard/personal-dashboard-read-model.ts @@ -19,6 +19,19 @@ export class PersonalDashboardReadModel implements IPersonalDashboardReadModel { this.db = db; } + async getLatestHealthScores( + project: string, + count: number, + ): Promise { + const results = await this.db<{ health: number }>('flag_trends') + .select('health') + .orderBy('created_at', 'desc') + .where('project', project) + .limit(count); + + return results.map((row) => Number(row.health)); + } + async getPersonalProjects(userId: number): Promise { const result = await this.db<{ name: string; diff --git a/src/lib/features/personal-dashboard/personal-dashboard-service.ts b/src/lib/features/personal-dashboard/personal-dashboard-service.ts index fcbbe08733..022702eb12 100644 --- a/src/lib/features/personal-dashboard/personal-dashboard-service.ts +++ b/src/lib/features/personal-dashboard/personal-dashboard-service.ts @@ -136,11 +136,39 @@ export class PersonalDashboardService { type: role.type as PersonalDashboardProjectDetailsSchema['roles'][number]['type'], })); + const healthScores = + await this.personalDashboardReadModel.getLatestHealthScores( + projectId, + 8, + ); + let avgHealthCurrentWindow: number | null = null; + let avgHealthPastWindow: number | null = null; + + if (healthScores.length >= 4) { + avgHealthCurrentWindow = Math.round( + healthScores + .slice(0, 4) + .reduce((acc, score) => acc + score, 0) / 4, + ); + } + + if (healthScores.length >= 8) { + avgHealthPastWindow = Math.round( + healthScores + .slice(4, 8) + .reduce((acc, score) => acc + score, 0) / 4, + ); + } + return { latestEvents: formattedEvents, onboardingStatus, owners, roles: projectRoles, + insights: { + avgHealthCurrentWindow, + avgHealthPastWindow, + }, }; } diff --git a/src/lib/openapi/spec/personal-dashboard-project-details-schema.ts b/src/lib/openapi/spec/personal-dashboard-project-details-schema.ts index f580b6e21e..b2cba57d5d 100644 --- a/src/lib/openapi/spec/personal-dashboard-project-details-schema.ts +++ b/src/lib/openapi/spec/personal-dashboard-project-details-schema.ts @@ -7,8 +7,36 @@ export const personalDashboardProjectDetailsSchema = { type: 'object', description: 'Project details in personal dashboard', additionalProperties: false, - required: ['owners', 'roles', 'latestEvents', 'onboardingStatus'], + required: [ + 'owners', + 'roles', + 'latestEvents', + 'onboardingStatus', + 'insights', + ], properties: { + insights: { + type: 'object', + description: 'Insights for the project', + additionalProperties: false, + required: ['avgHealthCurrentWindow', 'avgHealthPastWindow'], + properties: { + avgHealthCurrentWindow: { + type: 'number', + description: + 'The average health score in the current window of the last 4 weeks', + example: 80, + nullable: true, + }, + avgHealthPastWindow: { + type: 'number', + description: + 'The average health score in the previous 4 weeks before the current window', + example: 70, + nullable: true, + }, + }, + }, onboardingStatus: projectOverviewSchema.properties.onboardingStatus, latestEvents: { type: 'array',