From 48b21591f674f51f672f96834faa45ed5bf0b4c4 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 11 Dec 2024 11:19:08 +0100 Subject: [PATCH] feat: productivity report trends visualization (#8956) --- src/lib/services/email-service.test.ts | 10 +- src/lib/services/email-service.ts | 28 +-- .../productivity-report-view-model.test.ts | 181 ++++++++++++++++++ .../productivity-report-view-model.ts | 78 ++++++++ .../productivity-report.html.mustache | 21 +- 5 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 src/mailtemplates/productivity-report/productivity-report-view-model.test.ts create mode 100644 src/mailtemplates/productivity-report/productivity-report-view-model.ts diff --git a/src/lib/services/email-service.test.ts b/src/lib/services/email-service.test.ts index f8967f6693..4e72aa3d45 100644 --- a/src/lib/services/email-service.test.ts +++ b/src/lib/services/email-service.test.ts @@ -126,6 +126,11 @@ test('Can send productivity report email', async () => { flagsCreated: 1, productionUpdates: 2, health: 99, + previousMonth: { + health: 89, + flagsCreated: 1, + productionUpdates: 3, + }, }, ); expect(content.from).toBe('noreply@getunleash.ai'); @@ -133,9 +138,11 @@ test('Can send productivity report email', async () => { expect(content.html.includes(`Productivity Report`)).toBe(true); expect(content.html.includes(`localhost/insights`)).toBe(true); expect(content.html.includes(`localhost/profile`)).toBe(true); - expect(content.html.includes(`#b0d182`)).toBe(true); + expect(content.html.includes('#68a611')).toBe(true); + expect(content.html.includes(`10% more than previous month`)).toBe(true); expect(content.text.includes(`localhost/insights`)).toBe(true); expect(content.text.includes(`localhost/profile`)).toBe(true); + expect(content.text.includes(`localhost/profile`)).toBe(true); }); test('Should add optional headers to productivity email', async () => { @@ -170,6 +177,7 @@ test('Should add optional headers to productivity email', async () => { flagsCreated: 1, productionUpdates: 2, health: 99, + previousMonth: null, }, ); diff --git a/src/lib/services/email-service.ts b/src/lib/services/email-service.ts index 432d7865f0..ec8c168bfe 100644 --- a/src/lib/services/email-service.ts +++ b/src/lib/services/email-service.ts @@ -5,6 +5,10 @@ import { existsSync, readFileSync } from 'fs'; import type { Logger } from '../logger'; import NotFoundError from '../error/notfound-error'; import type { IUnleashConfig } from '../types/option'; +import { + type ProductivityReportMetrics, + productivityReportViewModel, +} from '../../mailtemplates/productivity-report/productivity-report-view-model'; export interface IAuthOptions { user: string; @@ -460,29 +464,15 @@ export class EmailService { async sendProductivityReportEmail( userEmail: string, userName: string, - metrics: { - health: number; - flagsCreated: number; - productionUpdates: number; - }, + metrics: ProductivityReportMetrics, ): Promise { if (this.configured()) { - const context = { - userName, + const context = productivityReportViewModel({ + metrics, userEmail, - ...metrics, + userName, unleashUrl: this.config.server.unleashUrl, - healthColor() { - const healthRating = this.health; - const healthColor = - healthRating >= 0 && healthRating <= 24 - ? '#d93644' - : healthRating >= 25 && healthRating <= 74 - ? '#ffc46f' - : '#b0d182'; - return healthColor; - }, - }; + }); const template = 'productivity-report'; diff --git a/src/mailtemplates/productivity-report/productivity-report-view-model.test.ts b/src/mailtemplates/productivity-report/productivity-report-view-model.test.ts new file mode 100644 index 0000000000..3828de8fe2 --- /dev/null +++ b/src/mailtemplates/productivity-report/productivity-report-view-model.test.ts @@ -0,0 +1,181 @@ +import { + productivityReportViewModel, + type ProductivityReportMetrics, +} from './productivity-report-view-model'; + +const mockData = { + unleashUrl: 'http://example.com', + userEmail: 'user@example.com', + userName: 'Test User', +}; +const mockMetrics = { + health: 0, + flagsCreated: 0, + productionUpdates: 0, + previousMonth: { + health: 0, + flagsCreated: 0, + productionUpdates: 0, + }, +}; + +describe('productivityReportViewModel', () => { + describe('healthColor', () => { + it('returns RED for health between 0 and 24', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + health: 20, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.healthColor()).toBe('#d93644'); + }); + + it('returns ORANGE for health between 25 and 74', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + health: 50, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.healthColor()).toBe('#d76500'); + }); + + it('returns GREEN for health 75 or above', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + health: 80, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.healthColor()).toBe('#68a611'); + }); + }); + + describe('healthTrendMessage', () => { + it('returns correct trend message when health increased', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + health: 80, + previousMonth: { ...mockMetrics.previousMonth, health: 70 }, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.healthTrendMessage()).toBe( + " 10% more than previous month", + ); + }); + + it('returns correct trend message when health decreased', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + health: 60, + previousMonth: { ...mockMetrics.previousMonth, health: 70 }, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.healthTrendMessage()).toBe( + " 10% less than previous month", + ); + }); + + it('returns correct message when health is the same', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + health: 70, + previousMonth: { ...mockMetrics.previousMonth, health: 70 }, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.healthTrendMessage()).toBe('Same as last month'); + }); + }); + + describe('flagsCreatedTrendMessage', () => { + it('returns correct trend message for flagsCreated increase', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + flagsCreated: 10, + previousMonth: { + ...mockMetrics.previousMonth, + flagsCreated: 8, + }, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.flagsCreatedTrendMessage()).toBe( + " 2 more than previous month", + ); + }); + }); + + describe('productionUpdatedTrendMessage', () => { + it('returns correct trend message for productionUpdates decrease', () => { + const metrics: ProductivityReportMetrics = { + ...mockMetrics, + productionUpdates: 5, + previousMonth: { + ...mockMetrics.previousMonth, + productionUpdates: 8, + }, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.productionUpdatedTrendMessage()).toBe( + " 3 less than previous month", + ); + }); + }); + + describe('Missing previous month data', () => { + it('returns no trends messages', () => { + const metrics: ProductivityReportMetrics = { + health: 100, + flagsCreated: 10, + productionUpdates: 5, + previousMonth: null, + }; + + const viewModel = productivityReportViewModel({ + ...mockData, + metrics, + }); + + expect(viewModel.healthTrendMessage()).toBe(null); + expect(viewModel.flagsCreatedTrendMessage()).toBe(null); + expect(viewModel.productionUpdatedTrendMessage()).toBe(null); + }); + }); +}); diff --git a/src/mailtemplates/productivity-report/productivity-report-view-model.ts b/src/mailtemplates/productivity-report/productivity-report-view-model.ts new file mode 100644 index 0000000000..a113459df2 --- /dev/null +++ b/src/mailtemplates/productivity-report/productivity-report-view-model.ts @@ -0,0 +1,78 @@ +export type ProductivityReportMetrics = { + health: number; + flagsCreated: number; + productionUpdates: number; + previousMonth: { + health: number; + flagsCreated: number; + productionUpdates: number; + } | null; +}; + +const RED = '#d93644'; +const GREEN = '#68a611'; +const ORANGE = '#d76500'; + +export const productivityReportViewModel = ({ + unleashUrl, + userEmail, + userName, + metrics, +}: { + unleashUrl: string; + userEmail: string; + userName: string; + metrics: ProductivityReportMetrics; +}) => ({ + userName, + userEmail, + ...metrics, + unleashUrl, + healthColor() { + const healthRating = this.health; + const healthColor = + healthRating >= 0 && healthRating <= 24 + ? RED + : healthRating >= 25 && healthRating <= 74 + ? ORANGE + : GREEN; + return healthColor; + }, + healthTrendMessage() { + return this.previousMonthText( + '%', + this.health, + this.previousMonth?.health, + ); + }, + flagsCreatedTrendMessage() { + return this.previousMonthText( + '', + this.flagsCreated, + this.previousMonth?.flagsCreated, + ); + }, + productionUpdatedTrendMessage() { + return this.previousMonthText( + '', + this.productionUpdates, + this.previousMonth?.productionUpdates, + ); + }, + previousMonthText( + unit: '' | '%', + currentValue: number, + previousValue?: number, + ) { + if (previousValue == null) { + return null; + } + if (currentValue > previousValue) { + return ` ${currentValue - previousValue}${unit} more than previous month`; + } + if (previousValue > currentValue) { + return ` ${previousValue - currentValue}${unit} less than previous month`; + } + return `Same as last month`; + }, +}); diff --git a/src/mailtemplates/productivity-report/productivity-report.html.mustache b/src/mailtemplates/productivity-report/productivity-report.html.mustache index 5b8ed032c4..378ed4aeb3 100644 --- a/src/mailtemplates/productivity-report/productivity-report.html.mustache +++ b/src/mailtemplates/productivity-report/productivity-report.html.mustache @@ -28,7 +28,8 @@ style="margin: 0;padding: 36px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;">
{{health}}%
- your instance health + your instance health
+ {{{healthTrendMessage}}}
@@ -40,18 +41,20 @@
- {{flagsCreated}}
- flags created last month + style="margin: 0;padding: 42px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;"> + {{flagsCreated}}
+ flags created
+ {{{flagsCreatedTrendMessage}}}
- {{productionUpdates}}
- production updates last month + style="margin: 0;padding: 42px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;"> + {{productionUpdates}}
+ production updates
+ {{{productionUpdatedTrendMessage}}}