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`; +}