diff --git a/workspaces/adoption-insights/.changeset/chubby-seals-share.md b/workspaces/adoption-insights/.changeset/chubby-seals-share.md
new file mode 100644
index 0000000000..bb38b5c981
--- /dev/null
+++ b/workspaces/adoption-insights/.changeset/chubby-seals-share.md
@@ -0,0 +1,5 @@
+---
+'@red-hat-developer-hub/backstage-plugin-adoption-insights': minor
+---
+
+Add Est. Time Saved column to Top Templates Card
diff --git a/workspaces/adoption-insights/examples/template/template.yaml b/workspaces/adoption-insights/examples/template/template.yaml
index 33f262b49c..7973ce9e12 100644
--- a/workspaces/adoption-insights/examples/template/template.yaml
+++ b/workspaces/adoption-insights/examples/template/template.yaml
@@ -5,6 +5,8 @@ metadata:
name: example-nodejs-template
title: Example Node.js Template
description: An example template for the scaffolder that creates a simple Node.js service
+ annotations:
+ rhdh.redhat.com/time-saved: '180'
spec:
owner: user:guest
type: service
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/report-alpha.api.md b/workspaces/adoption-insights/plugins/adoption-insights/report-alpha.api.md
index 9080e6669c..33e83ab377 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/report-alpha.api.md
+++ b/workspaces/adoption-insights/plugins/adoption-insights/report-alpha.api.md
@@ -43,6 +43,7 @@ export const adoptionInsightsTranslationRef: TranslationRef<
readonly 'table.headers.lastUsed': string;
readonly 'table.headers.views': string;
readonly 'table.headers.executions': string;
+ readonly 'table.headers.estTimeSaved': string;
readonly 'table.headers.trend': string;
readonly 'table.headers.entity': string;
readonly 'table.pagination.topN': string;
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/Templates.tsx b/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/Templates.tsx
index 3321746523..00b38567bc 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/Templates.tsx
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/Templates.tsx
@@ -31,6 +31,7 @@ import Tooltip from '@mui/material/Tooltip';
import CardWrapper from '../CardWrapper';
import { TEMPLATE_TABLE_HEADERS } from '../../utils/constants';
+import { formatTotalTimeSaved } from '../../utils/formatTimeSaved';
import TableFooterPagination from '../CardFooter';
import { useTemplates } from '../../hooks/useTemplates';
@@ -169,6 +170,7 @@ const Templates = () => {
sx={{
width: '50%',
minWidth: 0,
+ overflow: 'hidden',
}}
>
@@ -193,9 +195,15 @@ const Templates = () => {
-
+
{Number(template.count).toLocaleString('en-US') ?? '--'}
+
+ {formatTotalTimeSaved(
+ entityMetadataMap[template.entityref]?.timeSaved,
+ template.count,
+ )}
+
);
})
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/__tests__/Templates.test.tsx b/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/__tests__/Templates.test.tsx
index e261428ebf..de2cd8e7e6 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/__tests__/Templates.test.tsx
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/components/Templates/__tests__/Templates.test.tsx
@@ -52,6 +52,7 @@ jest.mock('../../../hooks/useEntityMetadataMap', () => ({
entityMetadataMap: {
'template:default/example-go-template-1': {
title: 'Example Go Template 1',
+ timeSaved: '180',
},
'template:default/example-go-template-2': {
title: 'Example Go Template 2',
@@ -81,6 +82,7 @@ jest.mock('../../../utils/constants', () => ({
TEMPLATE_TABLE_HEADERS: [
{ id: 'name', titleKey: 'table.headers.name' },
{ id: 'executions', titleKey: 'table.headers.executions' },
+ { id: 'estTimeSaved', titleKey: 'table.headers.estTimeSaved' },
],
}));
@@ -142,15 +144,15 @@ describe('Templates', () => {
// eslint-disable-next-line jest/expect-expect
it('should display correct table headers', () => {
renderComponent();
- verifyHeaders(['Name', 'Executions']);
+ verifyHeaders(['Name', 'Executions', 'Est. Time Saved']);
});
// eslint-disable-next-line jest/expect-expect
it('should display correct data in table rows', () => {
renderComponent();
verifyTableData([
- ['Example Go Template 1', '10'],
- ['Example Go Template 2', '20'],
+ ['Example Go Template 1', '10', '30hrs'],
+ ['Example Go Template 2', '20', '—'],
]);
});
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/hooks/useEntityMetadataMap.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/hooks/useEntityMetadataMap.ts
index 2b9143f07e..5d90fe359d 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/hooks/useEntityMetadataMap.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/hooks/useEntityMetadataMap.ts
@@ -20,10 +20,13 @@ import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
+const TIME_SAVED_ANNOTATION = 'rhdh.redhat.com/time-saved';
+
type EntityMetadata = {
title?: string;
description?: string;
kind?: string;
+ timeSaved?: string;
};
type EntityMetadataMap = Record;
@@ -102,6 +105,8 @@ export const useEntityMetadataMap = (entityRefs: string[]) => {
title: entity?.metadata?.title?.trim(),
description: entity?.metadata?.description?.trim(),
kind: entity?.kind,
+ timeSaved:
+ entity?.metadata?.annotations?.[TIME_SAVED_ANNOTATION]?.trim(),
};
});
setEntityMetadataMap(nextMap);
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/de.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/de.ts
index 0b7c84f20c..3d0268e257 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/de.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/de.ts
@@ -81,6 +81,7 @@ const adoptionInsightsTranslationDe = createTranslationMessages({
'searches.week': 'Woche',
'table.headers.entity': 'Entity',
'table.headers.executions': 'Ausführungen',
+ 'table.headers.estTimeSaved': 'Gesch. Zeitersparnis',
'table.headers.kind': 'Art',
'table.headers.lastUsed': 'Zuletzt verwendet',
'table.headers.name': 'Name',
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/es.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/es.ts
index fcb2f8d3a6..a4a78b22b4 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/es.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/es.ts
@@ -81,6 +81,7 @@ const adoptionInsightsTranslationEs = createTranslationMessages({
'searches.week': 'semana',
'table.headers.entity': 'Entidad',
'table.headers.executions': 'Ejecuciones',
+ 'table.headers.estTimeSaved': 'Tiempo estimado ahorrado',
'table.headers.kind': 'Tipo',
'table.headers.lastUsed': 'Último uso',
'table.headers.name': 'Nombre',
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/fr.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/fr.ts
index 5a6be723c6..277ffde285 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/fr.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/fr.ts
@@ -81,6 +81,7 @@ const adoptionInsightsTranslationFr = createTranslationMessages({
'searches.week': '1 semaine',
'table.headers.entity': 'Entité',
'table.headers.executions': 'Exécutions',
+ 'table.headers.estTimeSaved': 'Temps estimé économisé',
'table.headers.kind': 'Type',
'table.headers.lastUsed': 'Dernière utilisation',
'table.headers.name': 'Nom',
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/it.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/it.ts
index 3629668142..3bee068566 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/it.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/it.ts
@@ -81,6 +81,7 @@ const adoptionInsightsTranslationIt = createTranslationMessages({
'searches.week': 'settimana',
'table.headers.entity': 'Entità',
'table.headers.executions': 'Esecuzioni',
+ 'table.headers.estTimeSaved': 'Tempo stimato risparmiato',
'table.headers.kind': 'Tipo',
'table.headers.lastUsed': 'Ultimo utilizzo',
'table.headers.name': 'Nome',
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ja.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ja.ts
index 4bab396fa6..6a9394bb7a 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ja.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ja.ts
@@ -80,6 +80,7 @@ const adoptionInsightsTranslationJa = createTranslationMessages({
'searches.week': '週',
'table.headers.entity': 'エンティティー',
'table.headers.executions': '実行回数',
+ 'table.headers.estTimeSaved': '推定節約時間',
'table.headers.kind': '種類',
'table.headers.lastUsed': '最終使用',
'table.headers.name': '名前',
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ref.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ref.ts
index 2cbd613b18..1c5f5c7e81 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ref.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/translations/ref.ts
@@ -103,6 +103,7 @@ export const adoptionInsightsMessages = {
lastUsed: 'Last used',
views: 'Views',
executions: 'Executions',
+ estTimeSaved: 'Est. Time Saved',
trend: 'Trend',
entity: 'Entity',
},
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/utils/__tests__/constants.test.tsx b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/__tests__/constants.test.tsx
index 5fb6271439..02b9f5d9b2 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/utils/__tests__/constants.test.tsx
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/__tests__/constants.test.tsx
@@ -63,6 +63,7 @@ describe('Constants', () => {
expect(TEMPLATE_TABLE_HEADERS).toEqual([
{ id: 'name', titleKey: 'table.headers.name' },
{ id: 'executions', titleKey: 'table.headers.executions' },
+ { id: 'estTimeSaved', titleKey: 'table.headers.estTimeSaved' },
]);
});
});
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/utils/constants.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/constants.ts
index 9b6902c21d..ea7b6866df 100644
--- a/workspaces/adoption-insights/plugins/adoption-insights/src/utils/constants.ts
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/constants.ts
@@ -45,4 +45,5 @@ export const TECHDOCS_TABLE_HEADERS = [
export const TEMPLATE_TABLE_HEADERS = [
{ id: 'name', titleKey: 'table.headers.name' },
{ id: 'executions', titleKey: 'table.headers.executions' },
+ { id: 'estTimeSaved', titleKey: 'table.headers.estTimeSaved' },
];
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/utils/formatTimeSaved.test.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/formatTimeSaved.test.ts
new file mode 100644
index 0000000000..6870a1ce8c
--- /dev/null
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/formatTimeSaved.test.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { formatTotalTimeSaved } from './formatTimeSaved';
+
+describe('formatTotalTimeSaved', () => {
+ it('returns dash when annotation is undefined', () => {
+ expect(formatTotalTimeSaved(undefined, 5)).toBe('—');
+ });
+
+ it('returns dash when annotation is empty string', () => {
+ expect(formatTotalTimeSaved('', 5)).toBe('—');
+ });
+
+ it('returns dash when annotation is non-numeric', () => {
+ expect(formatTotalTimeSaved('abc', 5)).toBe('—');
+ });
+
+ it('returns dash when annotation is zero', () => {
+ expect(formatTotalTimeSaved('0', 5)).toBe('—');
+ });
+
+ it('returns dash when annotation is negative', () => {
+ expect(formatTotalTimeSaved('-10', 5)).toBe('—');
+ });
+
+ it('returns minutes when total is under 60', () => {
+ expect(formatTotalTimeSaved('10', 3)).toBe('30min');
+ });
+
+ it('returns whole hours', () => {
+ expect(formatTotalTimeSaved('60', 2)).toBe('2hrs');
+ expect(formatTotalTimeSaved('180', 1)).toBe('3hrs');
+ });
+
+ it('returns fractional hours', () => {
+ expect(formatTotalTimeSaved('30', 3)).toBe('1.5hrs');
+ expect(formatTotalTimeSaved('90', 3)).toBe('4.5hrs');
+ });
+
+ it('handles count of zero', () => {
+ expect(formatTotalTimeSaved('180', 0)).toBe('—');
+ });
+
+ it('handles large values', () => {
+ expect(formatTotalTimeSaved('60', 100)).toBe('100hrs');
+ });
+});
diff --git a/workspaces/adoption-insights/plugins/adoption-insights/src/utils/formatTimeSaved.ts b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/formatTimeSaved.ts
new file mode 100644
index 0000000000..533d682725
--- /dev/null
+++ b/workspaces/adoption-insights/plugins/adoption-insights/src/utils/formatTimeSaved.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export function formatTotalTimeSaved(
+ timeSavedMinutes: string | undefined,
+ count: number,
+): string {
+ if (!timeSavedMinutes) {
+ return '—';
+ }
+ const parsed = Number(timeSavedMinutes);
+ if (!Number.isFinite(parsed) || parsed <= 0 || count <= 0) {
+ return '—';
+ }
+ const totalMinutes = parsed * count;
+ if (totalMinutes < 60) {
+ return `${totalMinutes}min`;
+ }
+ const hours = totalMinutes / 60;
+ if (Number.isInteger(hours)) {
+ return `${hours}hrs`;
+ }
+ return `${hours.toFixed(1)}hrs`;
+}