mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
feat: productivity report trends visualization (#8956)
This commit is contained in:
parent
311df82d37
commit
48b21591f6
@ -126,6 +126,11 @@ test('Can send productivity report email', async () => {
|
|||||||
flagsCreated: 1,
|
flagsCreated: 1,
|
||||||
productionUpdates: 2,
|
productionUpdates: 2,
|
||||||
health: 99,
|
health: 99,
|
||||||
|
previousMonth: {
|
||||||
|
health: 89,
|
||||||
|
flagsCreated: 1,
|
||||||
|
productionUpdates: 3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(content.from).toBe('noreply@getunleash.ai');
|
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(`Productivity Report`)).toBe(true);
|
||||||
expect(content.html.includes(`localhost/insights`)).toBe(true);
|
expect(content.html.includes(`localhost/insights`)).toBe(true);
|
||||||
expect(content.html.includes(`localhost/profile`)).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/insights`)).toBe(true);
|
||||||
expect(content.text.includes(`localhost/profile`)).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 () => {
|
test('Should add optional headers to productivity email', async () => {
|
||||||
@ -170,6 +177,7 @@ test('Should add optional headers to productivity email', async () => {
|
|||||||
flagsCreated: 1,
|
flagsCreated: 1,
|
||||||
productionUpdates: 2,
|
productionUpdates: 2,
|
||||||
health: 99,
|
health: 99,
|
||||||
|
previousMonth: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -5,6 +5,10 @@ import { existsSync, readFileSync } from 'fs';
|
|||||||
import type { Logger } from '../logger';
|
import type { Logger } from '../logger';
|
||||||
import NotFoundError from '../error/notfound-error';
|
import NotFoundError from '../error/notfound-error';
|
||||||
import type { IUnleashConfig } from '../types/option';
|
import type { IUnleashConfig } from '../types/option';
|
||||||
|
import {
|
||||||
|
type ProductivityReportMetrics,
|
||||||
|
productivityReportViewModel,
|
||||||
|
} from '../../mailtemplates/productivity-report/productivity-report-view-model';
|
||||||
|
|
||||||
export interface IAuthOptions {
|
export interface IAuthOptions {
|
||||||
user: string;
|
user: string;
|
||||||
@ -460,29 +464,15 @@ export class EmailService {
|
|||||||
async sendProductivityReportEmail(
|
async sendProductivityReportEmail(
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
userName: string,
|
userName: string,
|
||||||
metrics: {
|
metrics: ProductivityReportMetrics,
|
||||||
health: number;
|
|
||||||
flagsCreated: number;
|
|
||||||
productionUpdates: number;
|
|
||||||
},
|
|
||||||
): Promise<IEmailEnvelope> {
|
): Promise<IEmailEnvelope> {
|
||||||
if (this.configured()) {
|
if (this.configured()) {
|
||||||
const context = {
|
const context = productivityReportViewModel({
|
||||||
userName,
|
metrics,
|
||||||
userEmail,
|
userEmail,
|
||||||
...metrics,
|
userName,
|
||||||
unleashUrl: this.config.server.unleashUrl,
|
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';
|
const template = 'productivity-report';
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
"<span style='color: #68a611'>▲</span> 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(
|
||||||
|
"<span style='color: #d93644'>▼</span> 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(
|
||||||
|
"<span style='color: #68a611'>▲</span> 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(
|
||||||
|
"<span style='color: #d93644'>▼</span> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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 `<span style='color: ${GREEN}'>▲</span> ${currentValue - previousValue}${unit} more than previous month`;
|
||||||
|
}
|
||||||
|
if (previousValue > currentValue) {
|
||||||
|
return `<span style='color: ${RED}'>▼</span> ${previousValue - currentValue}${unit} less than previous month`;
|
||||||
|
}
|
||||||
|
return `Same as last month`;
|
||||||
|
},
|
||||||
|
});
|
@ -28,7 +28,8 @@
|
|||||||
style="margin: 0;padding: 36px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;">
|
style="margin: 0;padding: 36px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;">
|
||||||
<div style="padding-top: 12px;">
|
<div style="padding-top: 12px;">
|
||||||
<span style="color: {{healthColor}};">{{health}}%</span><br>
|
<span style="color: {{healthColor}};">{{health}}%</span><br>
|
||||||
<span style="font-size: 16px; color: #888;">your instance health</span>
|
<span style="font-size: 16px; color: #1A4049; font-weight: 700">your instance health</span><br>
|
||||||
|
<span style="font-size: 12px; color: #6E6E70; font-weight: 400; line-height: 14px">{{{healthTrendMessage}}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -40,18 +41,20 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="text-align: center;">
|
<td style="text-align: center;">
|
||||||
<div class="shaded"
|
<div class="shaded"
|
||||||
style="margin: 0;padding: 42px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;">
|
style="margin: 0;padding: 42px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;">
|
||||||
<span
|
<span
|
||||||
style="font-size: 24px; font-weight: bold; color: #1a4049;">{{flagsCreated}}</span><br>
|
style="font-size: 24px; font-weight: bold; color: #1a4049;">{{flagsCreated}}</span><br>
|
||||||
<span style="color: #888;display: block;height: 20px;">flags created last month</span>
|
<span style="color: #1A4049;display: block;height: 16px;">flags created</span><br>
|
||||||
|
<span style="font-size: 12px; color: #6E6E70; font-weight: 400;">{{{flagsCreatedTrendMessage}}}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center;">
|
<td style="text-align: center;">
|
||||||
<div class="shaded"
|
<div class="shaded"
|
||||||
style="margin: 0;padding: 42px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;">
|
style="margin: 0;padding: 42px 8px;background: #f0f0f5;border-width: 3px;border-color: #ffffff;border-style: solid;">
|
||||||
<span
|
<span
|
||||||
style="font-size: 24px; font-weight: bold; color: #1a4049;">{{productionUpdates}}</span><br>
|
style="font-size: 24px; font-weight: bold; color: #1a4049;">{{productionUpdates}}</span><br>
|
||||||
<span style="color: #888;display: block;height: 20px;">production updates last month</span>
|
<span style="color: #1A4049;display: block;height: 16px;">production updates</span><br>
|
||||||
|
<span style="font-size: 12px; color: #6E6E70; font-weight: 400;">{{{productionUpdatedTrendMessage}}}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
Loading…
Reference in New Issue
Block a user