From 06e8593a971c09f2ffa8ec3c8a68c05e37b91cef Mon Sep 17 00:00:00 2001 From: francobanfi Date: Wed, 17 Dec 2025 12:25:24 -0300 Subject: [PATCH 01/22] adding metrics calculator with first metric case --- .../src/components/MetricCard.tsx | 32 ++++++++ .../src/locations/Page.styles.ts | 9 +++ .../src/locations/Page.tsx | 46 +++++++++-- .../src/metrics/MetricsCalculator.ts | 76 +++++++++++++++++++ 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 apps/content-production-dashboard/src/components/MetricCard.tsx create mode 100644 apps/content-production-dashboard/src/locations/Page.styles.ts create mode 100644 apps/content-production-dashboard/src/metrics/MetricsCalculator.ts diff --git a/apps/content-production-dashboard/src/components/MetricCard.tsx b/apps/content-production-dashboard/src/components/MetricCard.tsx new file mode 100644 index 0000000000..b642725671 --- /dev/null +++ b/apps/content-production-dashboard/src/components/MetricCard.tsx @@ -0,0 +1,32 @@ +import { Card, Flex, Text } from '@contentful/f36-components'; +import type { IconProps } from '@contentful/f36-icons'; +import type { ComponentType } from 'react'; + +export type MetricCardProps = { + title: string; + value: string; + subtitle: string; + icon: ComponentType; + isNegative?: boolean; +}; + +export const MetricCard = ({ title, value, subtitle, icon: Icon, isNegative }: MetricCardProps) => { + return ( + + + + + {title} + + + {value} + + + {subtitle} + + + + + + ); +}; diff --git a/apps/content-production-dashboard/src/locations/Page.styles.ts b/apps/content-production-dashboard/src/locations/Page.styles.ts new file mode 100644 index 0000000000..0f2c2a2e46 --- /dev/null +++ b/apps/content-production-dashboard/src/locations/Page.styles.ts @@ -0,0 +1,9 @@ +import tokens from '@contentful/f36-tokens'; +import { CSSProperties } from 'react'; + +export const styles = { + container: { + padding: tokens.spacingL, + backgroundColor: tokens.colorWhite, + } as CSSProperties, +}; diff --git a/apps/content-production-dashboard/src/locations/Page.tsx b/apps/content-production-dashboard/src/locations/Page.tsx index ab0f229e73..72cc048066 100644 --- a/apps/content-production-dashboard/src/locations/Page.tsx +++ b/apps/content-production-dashboard/src/locations/Page.tsx @@ -1,11 +1,47 @@ -import { PageAppSDK } from '@contentful/app-sdk'; -import { Paragraph } from '@contentful/f36-components'; -import { useSDK } from '@contentful/react-apps-toolkit'; +import { Button, Flex, Heading } from '@contentful/f36-components'; +import { ArrowClockwiseIcon } from '@contentful/f36-icons'; +import { styles } from './Page.styles'; +import { MetricCard } from '../components/MetricCard'; +import { MetricsCalculator } from '../metrics/MetricsCalculator'; +import { EntryProps } from 'contentful-management'; const Page = () => { - const sdk = useSDK(); + // TODO (fetching ticket): replace this with the real fetched entries. + const entries: EntryProps[] = []; + const metrics = new MetricsCalculator(entries).metrics; - return Hello Page Component (AppId: {sdk.ids.app}); + return ( + + {/* Header */} + + Content Dashboard + + + + {/* Metrics Cards */} + + {metrics.map((metric) => { + return ( + + ); + })} + + + ); }; export default Page; diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts new file mode 100644 index 0000000000..e297400381 --- /dev/null +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -0,0 +1,76 @@ +import { FileIcon } from '@contentful/f36-icons'; +import type { EntryProps } from 'contentful-management'; +import { MetricCardProps } from '../components/MetricCard'; + +const msPerDay = 24 * 60 * 60 * 1000; + +function toDate(value: unknown) { + if (!value) return undefined; + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value; + if (typeof value !== 'string') return undefined; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +function subDays(base: Date, days: number): Date { + return new Date(base.getTime() - days * msPerDay); +} + +function isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { + return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); +} + +function percentChange(current: number, previous: number): { text: string; isNegative: boolean } { + if (previous === 0) { + if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; + return { text: 'New publishing this month', isNegative: false }; + } + const pct = ((current - previous) / previous) * 100; + const abs = Math.abs(pct).toFixed(1); + const direction = pct < 0 ? 'decrease' : 'increase'; + return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; +} + +const getPublishedAt = (entry: EntryProps): Date | undefined => { + return toDate(entry?.sys?.publishedAt ?? entry?.sys?.firstPublishedAt); +}; + +export class MetricsCalculator { + private readonly entries: ReadonlyArray; + private readonly now: Date; // to maintain all the metrics consistent at the same current time + + public readonly metrics: ReadonlyArray; + + constructor(entries: ReadonlyArray) { + this.entries = entries; + this.now = new Date(); + + // Calculate once at construction time (per your request). + this.metrics = [this.calculateTotalPublished()]; + } + + private calculateTotalPublished(): MetricCardProps { + const startThisPeriod = subDays(this.now, 30); + const startPrevPeriod = subDays(this.now, 60); + const endPrevPeriod = startThisPeriod; + + const publishedDates = this.entries + .map((e) => getPublishedAt(e)) + .filter((d): d is Date => Boolean(d)); + + const current = publishedDates.filter((d) => isWithin(d, startThisPeriod, this.now)).length; + const previous = publishedDates.filter((d) => + isWithin(d, startPrevPeriod, endPrevPeriod) + ).length; + + const { text, isNegative } = percentChange(current, previous); + + return { + title: 'Total Published', + value: String(current), + subtitle: text, + icon: FileIcon, + isNegative, + }; + } +} From 3d5d3351b51063ab1b24e43fc913d0eef0b0e7ec Mon Sep 17 00:00:00 2001 From: francobanfi Date: Wed, 17 Dec 2025 14:17:44 -0300 Subject: [PATCH 02/22] adding average time to publish metric --- .../src/metrics/MetricsCalculator.ts | 78 ++++++++++++++----- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index e297400381..44336ba0d8 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -1,15 +1,16 @@ import { FileIcon } from '@contentful/f36-icons'; import type { EntryProps } from 'contentful-management'; -import { MetricCardProps } from '../components/MetricCard'; +import { ClockIcon } from '@contentful/f36-icons'; +import type { MetricCardProps } from '../components/MetricCard'; const msPerDay = 24 * 60 * 60 * 1000; -function toDate(value: unknown) { +type MaybeDate = Date | undefined; + +function parseDate(value: string | undefined): MaybeDate { if (!value) return undefined; - if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value; - if (typeof value !== 'string') return undefined; - const d = new Date(value); - return Number.isNaN(d.getTime()) ? undefined : d; + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : new Date(ms); } function subDays(base: Date, days: number): Date { @@ -31,10 +32,6 @@ function percentChange(current: number, previous: number): { text: string; isNeg return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; } -const getPublishedAt = (entry: EntryProps): Date | undefined => { - return toDate(entry?.sys?.publishedAt ?? entry?.sys?.firstPublishedAt); -}; - export class MetricsCalculator { private readonly entries: ReadonlyArray; private readonly now: Date; // to maintain all the metrics consistent at the same current time @@ -46,7 +43,15 @@ export class MetricsCalculator { this.now = new Date(); // Calculate once at construction time (per your request). - this.metrics = [this.calculateTotalPublished()]; + this.metrics = [this.calculateTotalPublished(), this.calculateAverageTimeToPublish()]; + } + + private getPublishedAt(entry: EntryProps): MaybeDate { + return parseDate(entry?.sys?.publishedAt); + } + + private getCreatedAt(entry: EntryProps): MaybeDate { + return parseDate(entry?.sys?.createdAt); } private calculateTotalPublished(): MetricCardProps { @@ -54,14 +59,20 @@ export class MetricsCalculator { const startPrevPeriod = subDays(this.now, 60); const endPrevPeriod = startThisPeriod; - const publishedDates = this.entries - .map((e) => getPublishedAt(e)) - .filter((d): d is Date => Boolean(d)); + let current = 0; + let previous = 0; + for (const entry of this.entries) { + const publishedAt = this.getPublishedAt(entry); + if (!publishedAt) continue; - const current = publishedDates.filter((d) => isWithin(d, startThisPeriod, this.now)).length; - const previous = publishedDates.filter((d) => - isWithin(d, startPrevPeriod, endPrevPeriod) - ).length; + if (isWithin(publishedAt, startThisPeriod, this.now)) { + current += 1; + continue; + } + if (isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) { + previous += 1; + } + } const { text, isNegative } = percentChange(current, previous); @@ -73,4 +84,35 @@ export class MetricsCalculator { isNegative, }; } + + private calculateAverageTimeToPublish(): MetricCardProps { + const startThisPeriod = subDays(this.now, 30); + + let sumDays = 0; + let count = 0; + for (const entry of this.entries) { + const publishedAt = this.getPublishedAt(entry); + if (!publishedAt) continue; + if (!isWithin(publishedAt, startThisPeriod, this.now)) continue; + + const createdAt = this.getCreatedAt(entry); + if (!createdAt) continue; + + const deltaDays = (publishedAt.getTime() - createdAt.getTime()) / msPerDay; + if (deltaDays < 0) continue; + + sumDays += deltaDays; + count += 1; + } + + const avg = count === 0 ? undefined : sumDays / count; + + return { + title: 'Average Time to Publish', + value: avg === undefined ? '—' : `${avg.toFixed(1)} days`, + subtitle: count === 0 ? 'No entries published in the last 30 days' : 'For the last 30 days', + icon: ClockIcon, + isNegative: false, + }; + } } From b2000d288e2dabf46209e173e9579733d96c6d27 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Wed, 17 Dec 2025 14:58:14 -0300 Subject: [PATCH 03/22] adding scheduled metric --- .../src/locations/Page.tsx | 105 +++++++++++++++++- .../src/metrics/MetricsCalculator.ts | 56 +++++++--- 2 files changed, 144 insertions(+), 17 deletions(-) diff --git a/apps/content-production-dashboard/src/locations/Page.tsx b/apps/content-production-dashboard/src/locations/Page.tsx index 72cc048066..5ae79e367c 100644 --- a/apps/content-production-dashboard/src/locations/Page.tsx +++ b/apps/content-production-dashboard/src/locations/Page.tsx @@ -3,12 +3,111 @@ import { ArrowClockwiseIcon } from '@contentful/f36-icons'; import { styles } from './Page.styles'; import { MetricCard } from '../components/MetricCard'; import { MetricsCalculator } from '../metrics/MetricsCalculator'; -import { EntryProps } from 'contentful-management'; +import { ScheduledActionStatus, EntryProps, ScheduledActionProps } from 'contentful-management'; const Page = () => { // TODO (fetching ticket): replace this with the real fetched entries. - const entries: EntryProps[] = []; - const metrics = new MetricsCalculator(entries).metrics; + // Mocked entries for UI testing (created/published dates are relative to "now"). + const now = new Date(); + const daysAgo = (days: number) => + new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); + const daysFromNow = (days: number) => + new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString(); + + const entries: EntryProps[] = [ + // Published in last 30 days, 5 days to publish + { + sys: { + createdAt: daysAgo(10), + publishedAt: daysAgo(5), + }, + } as unknown as EntryProps, + // Published in last 30 days, 2 days to publish + { + sys: { + createdAt: daysAgo(20), + publishedAt: daysAgo(18), + }, + } as unknown as EntryProps, + // Published in previous 30-day window (helps MoM comparison) + { + sys: { + createdAt: daysAgo(50), + publishedAt: daysAgo(40), + }, + } as unknown as EntryProps, + ]; + + // TODO (fetching ticket): replace this with cma.scheduledActions.getMany(...) + const scheduledActions: ScheduledActionProps[] = [ + // Within next 30 days + { + entity: { + sys: { type: 'Link', linkType: 'Entry', id: 'entry-1' }, + }, + environment: { + sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, + }, + scheduledFor: { datetime: daysFromNow(3), timezone: 'UTC' }, + action: 'publish', + sys: { + id: 'scheduled-action-1', + type: 'ScheduledAction', + status: ScheduledActionStatus.scheduled, + createdAt: daysAgo(1), + createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, + updatedAt: daysAgo(1), + updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + version: 1, + }, + }, + { + entity: { + sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' }, + }, + environment: { + sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, + }, + scheduledFor: { datetime: daysFromNow(15), timezone: 'UTC' }, + action: 'publish', + sys: { + id: 'scheduled-action-2', + type: 'ScheduledAction', + status: ScheduledActionStatus.scheduled, + createdAt: daysAgo(2), + createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, + updatedAt: daysAgo(2), + updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + version: 1, + }, + }, + // Outside next 30 days (should not count) + { + entity: { + sys: { type: 'Link', linkType: 'Entry', id: 'entry-3' }, + }, + environment: { + sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, + }, + scheduledFor: { datetime: daysFromNow(45), timezone: 'UTC' }, + action: 'publish', + sys: { + id: 'scheduled-action-3', + type: 'ScheduledAction', + status: ScheduledActionStatus.scheduled, + createdAt: daysAgo(3), + createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, + updatedAt: daysAgo(3), + updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + version: 1, + }, + }, + ]; + + const metrics = new MetricsCalculator(entries, scheduledActions).metrics; return ( diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index 44336ba0d8..b6c04f1fd0 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -1,7 +1,8 @@ import { FileIcon } from '@contentful/f36-icons'; -import type { EntryProps } from 'contentful-management'; +import type { EntryProps, ScheduledActionProps } from 'contentful-management'; import { ClockIcon } from '@contentful/f36-icons'; import type { MetricCardProps } from '../components/MetricCard'; +import { CalendarDotsIcon } from '@contentful/f36-icons'; const msPerDay = 24 * 60 * 60 * 1000; @@ -13,6 +14,10 @@ function parseDate(value: string | undefined): MaybeDate { return Number.isNaN(ms) ? undefined : new Date(ms); } +function addDays(base: Date, days: number): Date { + return new Date(base.getTime() + days * msPerDay); +} + function subDays(base: Date, days: number): Date { return new Date(base.getTime() - days * msPerDay); } @@ -34,24 +39,25 @@ function percentChange(current: number, previous: number): { text: string; isNeg export class MetricsCalculator { private readonly entries: ReadonlyArray; + private readonly scheduledActions: ReadonlyArray; private readonly now: Date; // to maintain all the metrics consistent at the same current time public readonly metrics: ReadonlyArray; - constructor(entries: ReadonlyArray) { + constructor( + entries: ReadonlyArray, + scheduledActions: ReadonlyArray + ) { this.entries = entries; + this.scheduledActions = scheduledActions; this.now = new Date(); // Calculate once at construction time (per your request). - this.metrics = [this.calculateTotalPublished(), this.calculateAverageTimeToPublish()]; - } - - private getPublishedAt(entry: EntryProps): MaybeDate { - return parseDate(entry?.sys?.publishedAt); - } - - private getCreatedAt(entry: EntryProps): MaybeDate { - return parseDate(entry?.sys?.createdAt); + this.metrics = [ + this.calculateTotalPublished(), + this.calculateAverageTimeToPublish(), + this.calculateScheduled(), + ]; } private calculateTotalPublished(): MetricCardProps { @@ -62,7 +68,7 @@ export class MetricsCalculator { let current = 0; let previous = 0; for (const entry of this.entries) { - const publishedAt = this.getPublishedAt(entry); + const publishedAt = parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; if (isWithin(publishedAt, startThisPeriod, this.now)) { @@ -91,11 +97,11 @@ export class MetricsCalculator { let sumDays = 0; let count = 0; for (const entry of this.entries) { - const publishedAt = this.getPublishedAt(entry); + const publishedAt = parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; if (!isWithin(publishedAt, startThisPeriod, this.now)) continue; - const createdAt = this.getCreatedAt(entry); + const createdAt = parseDate(entry?.sys?.createdAt); if (!createdAt) continue; const deltaDays = (publishedAt.getTime() - createdAt.getTime()) / msPerDay; @@ -115,4 +121,26 @@ export class MetricsCalculator { isNegative: false, }; } + + private calculateScheduled(): MetricCardProps { + const end = addDays(this.now, 30); + + let count = 0; + for (const action of this.scheduledActions) { + if (action?.sys?.status && action.sys.status !== 'scheduled') continue; + const scheduledFor = parseDate(action?.scheduledFor?.datetime); + if (!scheduledFor) continue; + if (isWithin(scheduledFor, this.now, end)) { + count += 1; + } + } + + return { + title: 'Scheduled', + value: String(count), + subtitle: 'For the next 30 days', + icon: CalendarDotsIcon, + isNegative: false, + }; + } } From 8f600cc63e826584c174b9b2cca1d3b0cf94db94 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Wed, 17 Dec 2025 15:31:07 -0300 Subject: [PATCH 04/22] adding recently published and needs update metrics --- .../src/locations/Page.tsx | 3 ++ .../src/metrics/MetricsCalculator.ts | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/apps/content-production-dashboard/src/locations/Page.tsx b/apps/content-production-dashboard/src/locations/Page.tsx index 5ae79e367c..f50d555c09 100644 --- a/apps/content-production-dashboard/src/locations/Page.tsx +++ b/apps/content-production-dashboard/src/locations/Page.tsx @@ -20,6 +20,7 @@ const Page = () => { sys: { createdAt: daysAgo(10), publishedAt: daysAgo(5), + updatedAt: daysAgo(5), }, } as unknown as EntryProps, // Published in last 30 days, 2 days to publish @@ -34,6 +35,8 @@ const Page = () => { sys: { createdAt: daysAgo(50), publishedAt: daysAgo(40), + // Older than 6 months → should count in "Needs Update" + updatedAt: daysAgo(220), }, } as unknown as EntryProps, ]; diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index b6c04f1fd0..55939cb2e0 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -3,6 +3,7 @@ import type { EntryProps, ScheduledActionProps } from 'contentful-management'; import { ClockIcon } from '@contentful/f36-icons'; import type { MetricCardProps } from '../components/MetricCard'; import { CalendarDotsIcon } from '@contentful/f36-icons'; +import { PenNibIcon } from '@contentful/f36-icons'; const msPerDay = 24 * 60 * 60 * 1000; @@ -22,6 +23,12 @@ function subDays(base: Date, days: number): Date { return new Date(base.getTime() - days * msPerDay); } +function subMonths(base: Date, months: number): Date { + const date = new Date(base); + date.setMonth(date.getMonth() - months); + return date; +} + function isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); } @@ -57,6 +64,8 @@ export class MetricsCalculator { this.calculateTotalPublished(), this.calculateAverageTimeToPublish(), this.calculateScheduled(), + this.calculateRecentlyPublished(), + this.calculateNeedsUpdate(), ]; } @@ -143,4 +152,46 @@ export class MetricsCalculator { isNegative: false, }; } + + private calculateRecentlyPublished(): MetricCardProps { + const start = subDays(this.now, 7); + + let count = 0; + for (const entry of this.entries) { + const publishedAt = parseDate(entry?.sys?.publishedAt); + if (!publishedAt) continue; + if (isWithin(publishedAt, start, this.now)) { + count += 1; + } + } + + return { + title: 'Recently Published', + value: String(count), + subtitle: 'In the last 7 days', + icon: ClockIcon, + isNegative: false, + }; + } + + private calculateNeedsUpdate(): MetricCardProps { + const cutoff = subMonths(this.now, 6); + + let count = 0; + for (const entry of this.entries) { + const updatedAt = parseDate(entry?.sys?.updatedAt); + if (!updatedAt) continue; + if (updatedAt.getTime() < cutoff.getTime()) { + count += 1; + } + } + + return { + title: 'Needs Update', + value: String(count), + subtitle: 'Content older than 6 months', + icon: PenNibIcon, + isNegative: false, + }; + } } From 176b8abc764ed287db430758b719395e2e7d0d9e Mon Sep 17 00:00:00 2001 From: francobanfi Date: Wed, 17 Dec 2025 15:46:26 -0300 Subject: [PATCH 05/22] adding installation parameters usage --- .../src/locations/Page.tsx | 12 ++++++- .../src/metrics/MetricsCalculator.ts | 34 +++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/content-production-dashboard/src/locations/Page.tsx b/apps/content-production-dashboard/src/locations/Page.tsx index f50d555c09..4c6a420eeb 100644 --- a/apps/content-production-dashboard/src/locations/Page.tsx +++ b/apps/content-production-dashboard/src/locations/Page.tsx @@ -4,8 +4,14 @@ import { styles } from './Page.styles'; import { MetricCard } from '../components/MetricCard'; import { MetricsCalculator } from '../metrics/MetricsCalculator'; import { ScheduledActionStatus, EntryProps, ScheduledActionProps } from 'contentful-management'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import type { AppInstallationParameters } from './ConfigScreen'; const Page = () => { + const sdk = useSDK(); + const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; + // TODO (fetching ticket): replace this with the real fetched entries. // Mocked entries for UI testing (created/published dates are relative to "now"). const now = new Date(); @@ -110,7 +116,11 @@ const Page = () => { }, ]; - const metrics = new MetricsCalculator(entries, scheduledActions).metrics; + const metrics = new MetricsCalculator(entries, scheduledActions, { + needsUpdateMonths: installation.needsUpdateMonths, + recentlyPublishedDays: installation.recentlyPublishedDays, + timeToPublishDays: installation.timeToPublishDays, + }).metrics; return ( diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index 55939cb2e0..2cd3304a63 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -4,6 +4,11 @@ import { ClockIcon } from '@contentful/f36-icons'; import type { MetricCardProps } from '../components/MetricCard'; import { CalendarDotsIcon } from '@contentful/f36-icons'; import { PenNibIcon } from '@contentful/f36-icons'; +import { + NEEDS_UPDATE_MONTHS_RANGE, + RECENTLY_PUBLISHED_DAYS_RANGE, + TIME_TO_PUBLISH_DAYS_RANGE, +} from '../utils/consts'; const msPerDay = 24 * 60 * 60 * 1000; @@ -48,16 +53,28 @@ export class MetricsCalculator { private readonly entries: ReadonlyArray; private readonly scheduledActions: ReadonlyArray; private readonly now: Date; // to maintain all the metrics consistent at the same current time + private readonly needsUpdateMonths: number; + private readonly recentlyPublishedDays: number; + private readonly timeToPublishDays: number; public readonly metrics: ReadonlyArray; constructor( entries: ReadonlyArray, - scheduledActions: ReadonlyArray + scheduledActions: ReadonlyArray, + options?: { + needsUpdateMonths?: number; + recentlyPublishedDays?: number; + timeToPublishDays?: number; + } ) { this.entries = entries; this.scheduledActions = scheduledActions; this.now = new Date(); + this.needsUpdateMonths = options?.needsUpdateMonths ?? NEEDS_UPDATE_MONTHS_RANGE.min; + this.recentlyPublishedDays = + options?.recentlyPublishedDays ?? RECENTLY_PUBLISHED_DAYS_RANGE.min; + this.timeToPublishDays = options?.timeToPublishDays ?? TIME_TO_PUBLISH_DAYS_RANGE.min; // Calculate once at construction time (per your request). this.metrics = [ @@ -101,7 +118,7 @@ export class MetricsCalculator { } private calculateAverageTimeToPublish(): MetricCardProps { - const startThisPeriod = subDays(this.now, 30); + const startThisPeriod = subDays(this.now, this.timeToPublishDays); let sumDays = 0; let count = 0; @@ -125,7 +142,10 @@ export class MetricsCalculator { return { title: 'Average Time to Publish', value: avg === undefined ? '—' : `${avg.toFixed(1)} days`, - subtitle: count === 0 ? 'No entries published in the last 30 days' : 'For the last 30 days', + subtitle: + count === 0 + ? `No entries published in the last ${this.timeToPublishDays} days` + : `For the last ${this.timeToPublishDays} days`, icon: ClockIcon, isNegative: false, }; @@ -154,7 +174,7 @@ export class MetricsCalculator { } private calculateRecentlyPublished(): MetricCardProps { - const start = subDays(this.now, 7); + const start = subDays(this.now, this.recentlyPublishedDays); let count = 0; for (const entry of this.entries) { @@ -168,14 +188,14 @@ export class MetricsCalculator { return { title: 'Recently Published', value: String(count), - subtitle: 'In the last 7 days', + subtitle: `In the last ${this.recentlyPublishedDays} days`, icon: ClockIcon, isNegative: false, }; } private calculateNeedsUpdate(): MetricCardProps { - const cutoff = subMonths(this.now, 6); + const cutoff = subMonths(this.now, this.needsUpdateMonths); let count = 0; for (const entry of this.entries) { @@ -189,7 +209,7 @@ export class MetricsCalculator { return { title: 'Needs Update', value: String(count), - subtitle: 'Content older than 6 months', + subtitle: `Content older than ${this.needsUpdateMonths} months`, icon: PenNibIcon, isNegative: false, }; From 138e0739d801088851109d88804dad0167f8c629 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 09:43:57 -0300 Subject: [PATCH 06/22] adding initial test for components = structure refactor --- .../src/components/Dashboard.tsx | 158 ++++++++++++++++++ .../src/locations/Home.tsx | 8 +- .../src/locations/Page.tsx | 156 +---------------- .../test/components/Dashboard.spec.tsx | 33 ++++ .../test/components/MetricCard.spec.tsx | 64 +++++++ .../test/locations/Home.spec.tsx | 17 -- .../test/locations/Page.spec.tsx | 17 -- .../test/mocks/mockSdk.ts | 3 + 8 files changed, 262 insertions(+), 194 deletions(-) create mode 100644 apps/content-production-dashboard/src/components/Dashboard.tsx create mode 100644 apps/content-production-dashboard/test/components/Dashboard.spec.tsx create mode 100644 apps/content-production-dashboard/test/components/MetricCard.spec.tsx delete mode 100644 apps/content-production-dashboard/test/locations/Home.spec.tsx delete mode 100644 apps/content-production-dashboard/test/locations/Page.spec.tsx diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx new file mode 100644 index 0000000000..d50afd1c4e --- /dev/null +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -0,0 +1,158 @@ +import { Button, Flex, Heading } from '@contentful/f36-components'; +import { ArrowClockwiseIcon } from '@contentful/f36-icons'; +import { MetricCard } from './MetricCard'; +import { MetricsCalculator } from '../metrics/MetricsCalculator'; +import { ScheduledActionStatus, EntryProps, ScheduledActionProps } from 'contentful-management'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import type { AppInstallationParameters } from '../locations/ConfigScreen'; +import { styles } from '../locations/Page.styles'; + +const Dashboard = () => { + const sdk = useSDK(); + const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; + + // TODO (fetching ticket): replace this with the real fetched entries. + // Mocked entries for UI testing (created/published dates are relative to "now"). + const now = new Date(); + const daysAgo = (days: number) => + new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); + const daysFromNow = (days: number) => + new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString(); + + const entries: EntryProps[] = [ + // Published in last 30 days, 5 days to publish + { + sys: { + createdAt: daysAgo(10), + publishedAt: daysAgo(5), + updatedAt: daysAgo(5), + }, + } as unknown as EntryProps, + // Published in last 30 days, 2 days to publish + { + sys: { + createdAt: daysAgo(20), + publishedAt: daysAgo(18), + }, + } as unknown as EntryProps, + // Published in previous 30-day window (helps MoM comparison) + { + sys: { + createdAt: daysAgo(50), + publishedAt: daysAgo(40), + // Older than 6 months → should count in "Needs Update" + updatedAt: daysAgo(220), + }, + } as unknown as EntryProps, + ]; + + // TODO (fetching ticket): replace this with cma.scheduledActions.getMany(...) + const scheduledActions: ScheduledActionProps[] = [ + // Within next 30 days + { + entity: { + sys: { type: 'Link', linkType: 'Entry', id: 'entry-1' }, + }, + environment: { + sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, + }, + scheduledFor: { datetime: daysFromNow(3), timezone: 'UTC' }, + action: 'publish', + sys: { + id: 'scheduled-action-1', + type: 'ScheduledAction', + status: ScheduledActionStatus.scheduled, + createdAt: daysAgo(1), + createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, + updatedAt: daysAgo(1), + updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + version: 1, + }, + }, + { + entity: { + sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' }, + }, + environment: { + sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, + }, + scheduledFor: { datetime: daysFromNow(15), timezone: 'UTC' }, + action: 'publish', + sys: { + id: 'scheduled-action-2', + type: 'ScheduledAction', + status: ScheduledActionStatus.scheduled, + createdAt: daysAgo(2), + createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, + updatedAt: daysAgo(2), + updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + version: 1, + }, + }, + // Outside next 30 days (should not count) + { + entity: { + sys: { type: 'Link', linkType: 'Entry', id: 'entry-3' }, + }, + environment: { + sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, + }, + scheduledFor: { datetime: daysFromNow(45), timezone: 'UTC' }, + action: 'publish', + sys: { + id: 'scheduled-action-3', + type: 'ScheduledAction', + status: ScheduledActionStatus.scheduled, + createdAt: daysAgo(3), + createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, + updatedAt: daysAgo(3), + updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, + version: 1, + }, + }, + ]; + + const metrics = new MetricsCalculator(entries, scheduledActions, { + needsUpdateMonths: installation.needsUpdateMonths, + recentlyPublishedDays: installation.recentlyPublishedDays, + timeToPublishDays: installation.timeToPublishDays, + }).metrics; + + return ( + + {/* Header */} + + Content Dashboard + + + + {/* Metrics Cards */} + + {metrics.map((metric) => { + return ( + + ); + })} + + + ); +}; + +export default Dashboard; diff --git a/apps/content-production-dashboard/src/locations/Home.tsx b/apps/content-production-dashboard/src/locations/Home.tsx index 5508bb22d1..fc75438e28 100644 --- a/apps/content-production-dashboard/src/locations/Home.tsx +++ b/apps/content-production-dashboard/src/locations/Home.tsx @@ -1,11 +1,7 @@ -import { HomeAppSDK } from '@contentful/app-sdk'; -import { Paragraph } from '@contentful/f36-components'; -import { useSDK } from '@contentful/react-apps-toolkit'; +import Dashboard from '../components/Dashboard'; const Home = () => { - const sdk = useSDK(); - - return Hello Home Component (AppId: {sdk.ids.app}); + return ; }; export default Home; diff --git a/apps/content-production-dashboard/src/locations/Page.tsx b/apps/content-production-dashboard/src/locations/Page.tsx index 4c6a420eeb..836647b7ef 100644 --- a/apps/content-production-dashboard/src/locations/Page.tsx +++ b/apps/content-production-dashboard/src/locations/Page.tsx @@ -1,159 +1,7 @@ -import { Button, Flex, Heading } from '@contentful/f36-components'; -import { ArrowClockwiseIcon } from '@contentful/f36-icons'; -import { styles } from './Page.styles'; -import { MetricCard } from '../components/MetricCard'; -import { MetricsCalculator } from '../metrics/MetricsCalculator'; -import { ScheduledActionStatus, EntryProps, ScheduledActionProps } from 'contentful-management'; -import { PageAppSDK } from '@contentful/app-sdk'; -import { useSDK } from '@contentful/react-apps-toolkit'; -import type { AppInstallationParameters } from './ConfigScreen'; +import Dashboard from '../components/Dashboard'; const Page = () => { - const sdk = useSDK(); - const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; - - // TODO (fetching ticket): replace this with the real fetched entries. - // Mocked entries for UI testing (created/published dates are relative to "now"). - const now = new Date(); - const daysAgo = (days: number) => - new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); - const daysFromNow = (days: number) => - new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString(); - - const entries: EntryProps[] = [ - // Published in last 30 days, 5 days to publish - { - sys: { - createdAt: daysAgo(10), - publishedAt: daysAgo(5), - updatedAt: daysAgo(5), - }, - } as unknown as EntryProps, - // Published in last 30 days, 2 days to publish - { - sys: { - createdAt: daysAgo(20), - publishedAt: daysAgo(18), - }, - } as unknown as EntryProps, - // Published in previous 30-day window (helps MoM comparison) - { - sys: { - createdAt: daysAgo(50), - publishedAt: daysAgo(40), - // Older than 6 months → should count in "Needs Update" - updatedAt: daysAgo(220), - }, - } as unknown as EntryProps, - ]; - - // TODO (fetching ticket): replace this with cma.scheduledActions.getMany(...) - const scheduledActions: ScheduledActionProps[] = [ - // Within next 30 days - { - entity: { - sys: { type: 'Link', linkType: 'Entry', id: 'entry-1' }, - }, - environment: { - sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, - }, - scheduledFor: { datetime: daysFromNow(3), timezone: 'UTC' }, - action: 'publish', - sys: { - id: 'scheduled-action-1', - type: 'ScheduledAction', - status: ScheduledActionStatus.scheduled, - createdAt: daysAgo(1), - createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, - updatedAt: daysAgo(1), - updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - version: 1, - }, - }, - { - entity: { - sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' }, - }, - environment: { - sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, - }, - scheduledFor: { datetime: daysFromNow(15), timezone: 'UTC' }, - action: 'publish', - sys: { - id: 'scheduled-action-2', - type: 'ScheduledAction', - status: ScheduledActionStatus.scheduled, - createdAt: daysAgo(2), - createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, - updatedAt: daysAgo(2), - updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - version: 1, - }, - }, - // Outside next 30 days (should not count) - { - entity: { - sys: { type: 'Link', linkType: 'Entry', id: 'entry-3' }, - }, - environment: { - sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, - }, - scheduledFor: { datetime: daysFromNow(45), timezone: 'UTC' }, - action: 'publish', - sys: { - id: 'scheduled-action-3', - type: 'ScheduledAction', - status: ScheduledActionStatus.scheduled, - createdAt: daysAgo(3), - createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, - updatedAt: daysAgo(3), - updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - version: 1, - }, - }, - ]; - - const metrics = new MetricsCalculator(entries, scheduledActions, { - needsUpdateMonths: installation.needsUpdateMonths, - recentlyPublishedDays: installation.recentlyPublishedDays, - timeToPublishDays: installation.timeToPublishDays, - }).metrics; - - return ( - - {/* Header */} - - Content Dashboard - - - - {/* Metrics Cards */} - - {metrics.map((metric) => { - return ( - - ); - })} - - - ); + return ; }; export default Page; diff --git a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx new file mode 100644 index 0000000000..fd3cbc2425 --- /dev/null +++ b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { mockCma, mockSdk } from '../mocks'; +import Dashboard from '../../src/components/Dashboard'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, + useCMA: () => mockCma, +})); + +describe('Dashboard component', () => { + it('renders the dashboard heading', () => { + render(); + + expect(screen.getByText('Content Dashboard')).toBeInTheDocument(); + }); + + it('renders the refresh button', () => { + render(); + + expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument(); + }); + + it('renders all metric cards', () => { + render(); + + expect(screen.getByText('Total Published')).toBeInTheDocument(); + expect(screen.getByText('Average Time to Publish')).toBeInTheDocument(); + expect(screen.getByText('Scheduled')).toBeTruthy(); + expect(screen.getByText('Recently Published')).toBeInTheDocument(); + expect(screen.getByText('Needs Update')).toBeInTheDocument(); + }); +}); diff --git a/apps/content-production-dashboard/test/components/MetricCard.spec.tsx b/apps/content-production-dashboard/test/components/MetricCard.spec.tsx new file mode 100644 index 0000000000..8662d4a18d --- /dev/null +++ b/apps/content-production-dashboard/test/components/MetricCard.spec.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { MetricCard } from '../../src/components/MetricCard'; +import { FileIcon } from '@contentful/f36-icons'; + +describe('MetricCard component', () => { + it('renders title, value, and subtitle', () => { + render( + + ); + + expect(screen.getByText('Test Metric')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Test subtitle')).toBeInTheDocument(); + }); + + it('renders the icon', () => { + render( + + ); + + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); + }); + + it('applies negative styling when isNegative is true', () => { + render( + + ); + + expect(screen.getByText('Test subtitle')).toBeInTheDocument(); + }); + + it('applies default styling when isNegative is false', () => { + render( + + ); + + expect(screen.getByText('Test subtitle')).toBeInTheDocument(); + }); +}); diff --git a/apps/content-production-dashboard/test/locations/Home.spec.tsx b/apps/content-production-dashboard/test/locations/Home.spec.tsx deleted file mode 100644 index 6cb3e0eccf..0000000000 --- a/apps/content-production-dashboard/test/locations/Home.spec.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { mockCma, mockSdk } from '../mocks'; -import Home from '../../src/locations/Home'; - -vi.mock('@contentful/react-apps-toolkit', () => ({ - useSDK: () => mockSdk, - useCMA: () => mockCma, -})); - -describe('Home component', () => { - it('Component text exists', () => { - const { getByText } = render(); - - expect(getByText('Hello Home Component (AppId: test-app)')).toBeTruthy(); - }); -}); diff --git a/apps/content-production-dashboard/test/locations/Page.spec.tsx b/apps/content-production-dashboard/test/locations/Page.spec.tsx deleted file mode 100644 index 1f199255de..0000000000 --- a/apps/content-production-dashboard/test/locations/Page.spec.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { mockCma, mockSdk } from '../mocks'; -import Page from '../../src/locations/Page'; - -vi.mock('@contentful/react-apps-toolkit', () => ({ - useSDK: () => mockSdk, - useCMA: () => mockCma, -})); - -describe('Page component', () => { - it('Component text exists', () => { - const { getByText } = render(); - - expect(getByText('Hello Page Component (AppId: test-app)')).toBeTruthy(); - }); -}); diff --git a/apps/content-production-dashboard/test/mocks/mockSdk.ts b/apps/content-production-dashboard/test/mocks/mockSdk.ts index 8762e239aa..45a55a9ca8 100644 --- a/apps/content-production-dashboard/test/mocks/mockSdk.ts +++ b/apps/content-production-dashboard/test/mocks/mockSdk.ts @@ -14,6 +14,9 @@ const mockSdk: any = { space: 'test-space', environment: 'test-environment', }, + parameters: { + installation: {}, + }, notifier: { error: vi.fn(), success: vi.fn(), From 3c4a6c5e293417dc9fe81acd97609d73750abc9b Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 10:09:02 -0300 Subject: [PATCH 07/22] adding metrics calculator tests --- .../test/metrics/MetricsCalculator.spec.ts | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts diff --git a/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts new file mode 100644 index 0000000000..33b5d4fc7d --- /dev/null +++ b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest'; +import { MetricsCalculator } from '../../src/metrics/MetricsCalculator'; +import type { EntryProps, ScheduledActionProps } from 'contentful-management'; +import { ScheduledActionStatus } from 'contentful-management'; +import { + NEEDS_UPDATE_MONTHS_RANGE, + RECENTLY_PUBLISHED_DAYS_RANGE, + TIME_TO_PUBLISH_DAYS_RANGE, +} from '../../src/utils/consts'; + +describe('MetricsCalculator', () => { + // Use actual current time to match MetricsCalculator's internal new Date() + const now = new Date(); + const daysAgo = (days: number) => + new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); + const daysFromNow = (days: number) => + new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString(); + + describe('constructor', () => { + it('initializes with empty arrays', () => { + const calculator = new MetricsCalculator([], []); + + expect(calculator.metrics).toHaveLength(5); + }); + + it('uses default values when options are not provided', () => { + const calculator = new MetricsCalculator([], []); + const needsUpdateMetric = calculator.metrics.find((m) => m.title === 'Needs Update'); + const recentlyPublishedMetric = calculator.metrics.find( + (m) => m.title === 'Recently Published' + ); + const avgTimeMetric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + + expect(needsUpdateMetric?.subtitle).toContain(`${NEEDS_UPDATE_MONTHS_RANGE.min} months`); + expect(recentlyPublishedMetric?.subtitle).toContain( + `${RECENTLY_PUBLISHED_DAYS_RANGE.min} days` + ); + expect(avgTimeMetric?.subtitle).toContain(`${TIME_TO_PUBLISH_DAYS_RANGE.min} days`); + }); + + it('uses provided options', () => { + const calculator = new MetricsCalculator([], [], { + needsUpdateMonths: 12, + recentlyPublishedDays: 14, + timeToPublishDays: 60, + }); + const needsUpdateMetric = calculator.metrics.find((m) => m.title === 'Needs Update'); + const recentlyPublishedMetric = calculator.metrics.find( + (m) => m.title === 'Recently Published' + ); + const avgTimeMetric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + + expect(needsUpdateMetric?.subtitle).toContain('12 months'); + expect(recentlyPublishedMetric?.subtitle).toContain('14 days'); + expect(avgTimeMetric?.subtitle).toContain('60 days'); + }); + }); + + describe('calculateTotalPublished', () => { + it('counts entries published in the last 30 days', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(10) } } as EntryProps, + { sys: { publishedAt: daysAgo(20) } } as EntryProps, + { sys: { publishedAt: daysAgo(40) } } as EntryProps, // Outside window + ]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + + expect(metric?.value).toBe('2'); + }); + + it('calculates MoM percentage change correctly', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(10) } } as EntryProps, // Current period + { sys: { publishedAt: daysAgo(40) } } as EntryProps, // Previous period + ]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + + expect(metric?.subtitle).toContain('% publishing'); + }); + + it('handles zero previous period', () => { + const entries: EntryProps[] = [{ sys: { publishedAt: daysAgo(10) } } as EntryProps]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + + expect(metric?.subtitle).toContain('New publishing this month'); + }); + + it('handles zero current and previous period', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(100) } } as EntryProps, // Outside both periods + ]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + + expect(metric?.value).toBe('0'); + expect(metric?.subtitle).toContain('0.0% publishing change MoM'); + }); + }); + + describe('calculateAverageTimeToPublish', () => { + it('calculates average time to publish correctly', () => { + const entries: EntryProps[] = [ + { + sys: { + createdAt: daysAgo(20), + publishedAt: daysAgo(10), // 10 days to publish + }, + } as EntryProps, + { + sys: { + createdAt: daysAgo(15), + publishedAt: daysAgo(10), // 5 days to publish + }, + } as EntryProps, + ]; + + const calculator = new MetricsCalculator(entries, [], { + timeToPublishDays: 30, + }); + const metric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + + expect(metric?.value).toBe('7.5 days'); + }); + + it('returns dash when no entries published in period', () => { + const entries: EntryProps[] = [ + { + sys: { + createdAt: daysAgo(50), + publishedAt: daysAgo(40), // Outside period + }, + } as EntryProps, + ]; + + const calculator = new MetricsCalculator(entries, [], { + timeToPublishDays: 30, + }); + const metric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + + expect(metric?.value).toBe('—'); + expect(metric?.subtitle).toContain('No entries published'); + }); + }); + + describe('calculateScheduled', () => { + it('counts scheduled actions in next 30 days', () => { + const scheduledActions: ScheduledActionProps[] = [ + { + scheduledFor: { datetime: daysFromNow(5), timezone: 'UTC' }, + sys: { status: ScheduledActionStatus.scheduled }, + } as ScheduledActionProps, + { + scheduledFor: { datetime: daysFromNow(20), timezone: 'UTC' }, + sys: { status: ScheduledActionStatus.scheduled }, + } as ScheduledActionProps, + { + scheduledFor: { datetime: daysFromNow(45), timezone: 'UTC' }, // Outside window + sys: { status: ScheduledActionStatus.scheduled }, + } as ScheduledActionProps, + ]; + + const calculator = new MetricsCalculator([], scheduledActions); + const metric = calculator.metrics.find((m) => m.title === 'Scheduled'); + + expect(metric?.value).toBe('2'); + }); + + it('ignores non-scheduled actions', () => { + const scheduledActions: ScheduledActionProps[] = [ + { + scheduledFor: { datetime: daysFromNow(5), timezone: 'UTC' }, + sys: { status: ScheduledActionStatus.scheduled }, + } as ScheduledActionProps, + { + scheduledFor: { datetime: daysFromNow(10), timezone: 'UTC' }, + sys: { status: 'cancelled' }, + } as unknown as ScheduledActionProps, + ]; + + const calculator = new MetricsCalculator([], scheduledActions); + const metric = calculator.metrics.find((m) => m.title === 'Scheduled'); + + expect(metric?.value).toBe('1'); + }); + }); + + describe('calculateRecentlyPublished', () => { + it('counts entries published in the specified days', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(3) } } as EntryProps, + { sys: { publishedAt: daysAgo(5) } } as EntryProps, + { sys: { publishedAt: daysAgo(10) } } as EntryProps, // Outside 7 day window + ]; + + const calculator = new MetricsCalculator(entries, [], { + recentlyPublishedDays: 7, + }); + const metric = calculator.metrics.find((m) => m.title === 'Recently Published'); + + expect(metric?.value).toBe('2'); + expect(metric?.subtitle).toContain('7 days'); + }); + + it('uses custom recentlyPublishedDays', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(10) } } as EntryProps, // Within 14 day window + ]; + + const calculator = new MetricsCalculator(entries, [], { + recentlyPublishedDays: 14, + }); + const metric = calculator.metrics.find((m) => m.title === 'Recently Published'); + + expect(metric?.value).toBe('1'); + expect(metric?.subtitle).toContain('14 days'); + }); + }); + + describe('calculateNeedsUpdate', () => { + it('counts entries older than specified months', () => { + const entries: EntryProps[] = [ + { sys: { updatedAt: daysAgo(30) } } as EntryProps, // Less than 6 months + { sys: { updatedAt: daysAgo(200) } } as EntryProps, // More than 6 months + { sys: { updatedAt: daysAgo(250) } } as EntryProps, // More than 6 months + ]; + + const calculator = new MetricsCalculator(entries, [], { + needsUpdateMonths: 6, + }); + const metric = calculator.metrics.find((m) => m.title === 'Needs Update'); + + expect(metric?.value).toBe('2'); + expect(metric?.subtitle).toContain('6 months'); + }); + + it('uses custom needsUpdateMonths', () => { + const entries: EntryProps[] = [ + { sys: { updatedAt: daysAgo(400) } } as EntryProps, // More than 12 months + ]; + + const calculator = new MetricsCalculator(entries, [], { + needsUpdateMonths: 12, + }); + const metric = calculator.metrics.find((m) => m.title === 'Needs Update'); + + expect(metric?.value).toBe('1'); + expect(metric?.subtitle).toContain('12 months'); + }); + + it('ignores entries without updatedAt', () => { + const entries: EntryProps[] = [ + { sys: {} } as EntryProps, + { sys: { updatedAt: daysAgo(200) } } as EntryProps, + ]; + + const calculator = new MetricsCalculator(entries, [], { + needsUpdateMonths: 6, + }); + const metric = calculator.metrics.find((m) => m.title === 'Needs Update'); + + expect(metric?.value).toBe('1'); + }); + }); +}); From 1d0141e40cd7e620f58aba8f4aab5a5cfc229ecf Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 10:19:39 -0300 Subject: [PATCH 08/22] changes in card styles --- .../src/components/Dashboard.tsx | 2 +- .../src/components/MetricCard.styles.ts | 10 ++++++++++ .../src/components/MetricCard.tsx | 6 ++++-- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 apps/content-production-dashboard/src/components/MetricCard.styles.ts diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index d50afd1c4e..930b135d9d 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -137,7 +137,7 @@ const Dashboard = () => { {/* Metrics Cards */} - + {metrics.map((metric) => { return ( { return ( - + @@ -25,7 +27,7 @@ export const MetricCard = ({ title, value, subtitle, icon: Icon, isNegative }: M {subtitle} - + ); From ccc006bc0ea533ba2a98895b545736e2759ea3c0 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 11:16:41 -0300 Subject: [PATCH 09/22] removing mocked entries and scheduled actions --- .../src/components/Dashboard.tsx | 108 +----------------- 1 file changed, 4 insertions(+), 104 deletions(-) diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index 930b135d9d..77d6c73abb 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -2,7 +2,7 @@ import { Button, Flex, Heading } from '@contentful/f36-components'; import { ArrowClockwiseIcon } from '@contentful/f36-icons'; import { MetricCard } from './MetricCard'; import { MetricsCalculator } from '../metrics/MetricsCalculator'; -import { ScheduledActionStatus, EntryProps, ScheduledActionProps } from 'contentful-management'; +import { EntryProps, ScheduledActionProps } from 'contentful-management'; import { useSDK } from '@contentful/react-apps-toolkit'; import type { AppInstallationParameters } from '../locations/ConfigScreen'; import { styles } from '../locations/Page.styles'; @@ -11,109 +11,9 @@ const Dashboard = () => { const sdk = useSDK(); const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; - // TODO (fetching ticket): replace this with the real fetched entries. - // Mocked entries for UI testing (created/published dates are relative to "now"). - const now = new Date(); - const daysAgo = (days: number) => - new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); - const daysFromNow = (days: number) => - new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString(); - - const entries: EntryProps[] = [ - // Published in last 30 days, 5 days to publish - { - sys: { - createdAt: daysAgo(10), - publishedAt: daysAgo(5), - updatedAt: daysAgo(5), - }, - } as unknown as EntryProps, - // Published in last 30 days, 2 days to publish - { - sys: { - createdAt: daysAgo(20), - publishedAt: daysAgo(18), - }, - } as unknown as EntryProps, - // Published in previous 30-day window (helps MoM comparison) - { - sys: { - createdAt: daysAgo(50), - publishedAt: daysAgo(40), - // Older than 6 months → should count in "Needs Update" - updatedAt: daysAgo(220), - }, - } as unknown as EntryProps, - ]; - - // TODO (fetching ticket): replace this with cma.scheduledActions.getMany(...) - const scheduledActions: ScheduledActionProps[] = [ - // Within next 30 days - { - entity: { - sys: { type: 'Link', linkType: 'Entry', id: 'entry-1' }, - }, - environment: { - sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, - }, - scheduledFor: { datetime: daysFromNow(3), timezone: 'UTC' }, - action: 'publish', - sys: { - id: 'scheduled-action-1', - type: 'ScheduledAction', - status: ScheduledActionStatus.scheduled, - createdAt: daysAgo(1), - createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, - updatedAt: daysAgo(1), - updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - version: 1, - }, - }, - { - entity: { - sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' }, - }, - environment: { - sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, - }, - scheduledFor: { datetime: daysFromNow(15), timezone: 'UTC' }, - action: 'publish', - sys: { - id: 'scheduled-action-2', - type: 'ScheduledAction', - status: ScheduledActionStatus.scheduled, - createdAt: daysAgo(2), - createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, - updatedAt: daysAgo(2), - updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - version: 1, - }, - }, - // Outside next 30 days (should not count) - { - entity: { - sys: { type: 'Link', linkType: 'Entry', id: 'entry-3' }, - }, - environment: { - sys: { type: 'Link', linkType: 'Environment', id: 'test-environment' }, - }, - scheduledFor: { datetime: daysFromNow(45), timezone: 'UTC' }, - action: 'publish', - sys: { - id: 'scheduled-action-3', - type: 'ScheduledAction', - status: ScheduledActionStatus.scheduled, - createdAt: daysAgo(3), - createdBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - space: { sys: { type: 'Link', linkType: 'Space', id: 'test-space' } }, - updatedAt: daysAgo(3), - updatedBy: { sys: { type: 'Link', linkType: 'User', id: 'user-1' } }, - version: 1, - }, - }, - ]; + // TODO : replace this with the real fetched entries. + const entries: EntryProps[] = []; + const scheduledActions: ScheduledActionProps[] = []; const metrics = new MetricsCalculator(entries, scheduledActions, { needsUpdateMonths: installation.needsUpdateMonths, From de4de4a6b8cc11bdaadef689189ebbf7bd69cd3b Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 11:35:22 -0300 Subject: [PATCH 10/22] mini refactors --- .../src/components/Dashboard.tsx | 2 - .../src/metrics/MetricsCalculator.ts | 42 +------------------ .../src/utils/dates.ts | 27 ++++++++++++ .../src/utils/metrics.ts | 13 ++++++ .../test/metrics/MetricsCalculator.spec.ts | 1 - 5 files changed, 42 insertions(+), 43 deletions(-) create mode 100644 apps/content-production-dashboard/src/utils/dates.ts create mode 100644 apps/content-production-dashboard/src/utils/metrics.ts diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index 77d6c73abb..bd83ef83e9 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -23,7 +23,6 @@ const Dashboard = () => { return ( - {/* Header */} Content Dashboard - {/* Metrics Cards */} {metrics.map((metric) => { return ( diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index 2cd3304a63..844439fef6 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -9,45 +9,8 @@ import { RECENTLY_PUBLISHED_DAYS_RANGE, TIME_TO_PUBLISH_DAYS_RANGE, } from '../utils/consts'; - -const msPerDay = 24 * 60 * 60 * 1000; - -type MaybeDate = Date | undefined; - -function parseDate(value: string | undefined): MaybeDate { - if (!value) return undefined; - const ms = Date.parse(value); - return Number.isNaN(ms) ? undefined : new Date(ms); -} - -function addDays(base: Date, days: number): Date { - return new Date(base.getTime() + days * msPerDay); -} - -function subDays(base: Date, days: number): Date { - return new Date(base.getTime() - days * msPerDay); -} - -function subMonths(base: Date, months: number): Date { - const date = new Date(base); - date.setMonth(date.getMonth() - months); - return date; -} - -function isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { - return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); -} - -function percentChange(current: number, previous: number): { text: string; isNegative: boolean } { - if (previous === 0) { - if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; - return { text: 'New publishing this month', isNegative: false }; - } - const pct = ((current - previous) / previous) * 100; - const abs = Math.abs(pct).toFixed(1); - const direction = pct < 0 ? 'decrease' : 'increase'; - return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; -} +import { msPerDay, parseDate, addDays, subDays, subMonths, isWithin } from '../utils/dates'; +import { percentChange } from '../utils/metrics'; export class MetricsCalculator { private readonly entries: ReadonlyArray; @@ -76,7 +39,6 @@ export class MetricsCalculator { options?.recentlyPublishedDays ?? RECENTLY_PUBLISHED_DAYS_RANGE.min; this.timeToPublishDays = options?.timeToPublishDays ?? TIME_TO_PUBLISH_DAYS_RANGE.min; - // Calculate once at construction time (per your request). this.metrics = [ this.calculateTotalPublished(), this.calculateAverageTimeToPublish(), diff --git a/apps/content-production-dashboard/src/utils/dates.ts b/apps/content-production-dashboard/src/utils/dates.ts new file mode 100644 index 0000000000..0b24e609f5 --- /dev/null +++ b/apps/content-production-dashboard/src/utils/dates.ts @@ -0,0 +1,27 @@ +export const msPerDay = 24 * 60 * 60 * 1000; + +export type MaybeDate = Date | undefined; + +export function parseDate(value: string | undefined): MaybeDate { + if (!value) return undefined; + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : new Date(ms); +} + +export function addDays(base: Date, days: number): Date { + return new Date(base.getTime() + days * msPerDay); +} + +export function subDays(base: Date, days: number): Date { + return new Date(base.getTime() - days * msPerDay); +} + +export function subMonths(base: Date, months: number): Date { + const date = new Date(base); + date.setMonth(date.getMonth() - months); + return date; +} + +export function isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { + return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); +} diff --git a/apps/content-production-dashboard/src/utils/metrics.ts b/apps/content-production-dashboard/src/utils/metrics.ts new file mode 100644 index 0000000000..132f6b291e --- /dev/null +++ b/apps/content-production-dashboard/src/utils/metrics.ts @@ -0,0 +1,13 @@ +export function percentChange( + current: number, + previous: number +): { text: string; isNegative: boolean } { + if (previous === 0) { + if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; + return { text: 'New publishing this month', isNegative: false }; + } + const pct = ((current - previous) / previous) * 100; + const abs = Math.abs(pct).toFixed(1); + const direction = pct < 0 ? 'decrease' : 'increase'; + return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; +} diff --git a/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts index 33b5d4fc7d..e7b63f71c2 100644 --- a/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts +++ b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts @@ -9,7 +9,6 @@ import { } from '../../src/utils/consts'; describe('MetricsCalculator', () => { - // Use actual current time to match MetricsCalculator's internal new Date() const now = new Date(); const daysAgo = (days: number) => new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); From 4ec1ccb13b8d4fe7e6dfabb3a0128d47738c9166 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 14:58:07 -0300 Subject: [PATCH 11/22] resolving conflicts after merging another PR --- .../src/components/Dashboard.tsx | 55 ++++++++++--------- .../test/components/Dashboard.spec.tsx | 35 ++++++++++-- 2 files changed, 60 insertions(+), 30 deletions(-) diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index bd83ef83e9..c788cf2fb2 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -1,18 +1,20 @@ import { Button, Flex, Heading } from '@contentful/f36-components'; -import { ArrowClockwiseIcon } from '@contentful/f36-icons'; import { MetricCard } from './MetricCard'; import { MetricsCalculator } from '../metrics/MetricsCalculator'; -import { EntryProps, ScheduledActionProps } from 'contentful-management'; +import { ScheduledActionProps } from 'contentful-management'; import { useSDK } from '@contentful/react-apps-toolkit'; import type { AppInstallationParameters } from '../locations/ConfigScreen'; import { styles } from '../locations/Page.styles'; +import { ErrorDisplay } from './ErrorDisplay'; +import { LoadingSkeleton } from './LoadingSkeleton'; +import { useAllEntries } from '../hooks/useAllEntries'; const Dashboard = () => { const sdk = useSDK(); const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; + const { entries, isFetching, error, refetch } = useAllEntries(); - // TODO : replace this with the real fetched entries. - const entries: EntryProps[] = []; + // TODO : replace this with the real scheduled actions. const scheduledActions: ScheduledActionProps[] = []; const metrics = new MetricsCalculator(entries, scheduledActions, { @@ -25,30 +27,33 @@ const Dashboard = () => { Content Dashboard - - - {metrics.map((metric) => { - return ( - - ); - })} - + {error ? ( + + ) : isFetching ? ( + + ) : ( + <> + + {metrics.map((metric) => { + return ( + + ); + })} + + + )} ); }; diff --git a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx index fd3cbc2425..0859fe1ba7 100644 --- a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx +++ b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx @@ -1,32 +1,57 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { mockCma, mockSdk } from '../mocks'; import Dashboard from '../../src/components/Dashboard'; +import { QueryProvider } from '../../src/providers/QueryProvider'; vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, useCMA: () => mockCma, })); +const mockRefetch = vi.fn(); +vi.mock('../../src/hooks/useAllEntries', () => ({ + useAllEntries: () => ({ + entries: [], + total: 0, + isFetching: false, + error: null, + refetch: mockRefetch, + fetchedAt: new Date(), + }), +})); + +const createWrapper = () => { + const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + TestWrapper.displayName = 'TestWrapper'; + return TestWrapper; +}; + describe('Dashboard component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('renders the dashboard heading', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Content Dashboard')).toBeInTheDocument(); }); it('renders the refresh button', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument(); }); it('renders all metric cards', () => { - render(); + render(, { wrapper: createWrapper() }); expect(screen.getByText('Total Published')).toBeInTheDocument(); expect(screen.getByText('Average Time to Publish')).toBeInTheDocument(); - expect(screen.getByText('Scheduled')).toBeTruthy(); + expect(screen.getByText('Scheduled')).toBeInTheDocument(); expect(screen.getByText('Recently Published')).toBeInTheDocument(); expect(screen.getByText('Needs Update')).toBeInTheDocument(); }); From 1ace79521e016b9db44448a938df7384ed5e3690 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 16:22:20 -0300 Subject: [PATCH 12/22] adding Home tests + removing refresh button --- .../src/components/Dashboard.tsx | 9 +-- .../test/components/Dashboard.spec.tsx | 6 -- .../test/locations/Home.spec.tsx | 72 +++++++++++++++++++ .../test/locations/Page.spec.tsx | 54 -------------- 4 files changed, 75 insertions(+), 66 deletions(-) create mode 100644 apps/content-production-dashboard/test/locations/Home.spec.tsx diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index c788cf2fb2..b299548190 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import { Button, Flex, Heading } from '@contentful/f36-components'; +import { Flex, Box, Heading } from '@contentful/f36-components'; import { MetricCard } from './MetricCard'; import { MetricsCalculator } from '../metrics/MetricsCalculator'; import { ScheduledActionProps } from 'contentful-management'; @@ -25,12 +25,9 @@ const Dashboard = () => { return ( - + Content Dashboard - - + {error ? ( diff --git a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx index 0859fe1ba7..18bb45b914 100644 --- a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx +++ b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx @@ -40,12 +40,6 @@ describe('Dashboard component', () => { expect(screen.getByText('Content Dashboard')).toBeInTheDocument(); }); - it('renders the refresh button', () => { - render(, { wrapper: createWrapper() }); - - expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument(); - }); - it('renders all metric cards', () => { render(, { wrapper: createWrapper() }); diff --git a/apps/content-production-dashboard/test/locations/Home.spec.tsx b/apps/content-production-dashboard/test/locations/Home.spec.tsx new file mode 100644 index 0000000000..8dea5c25fc --- /dev/null +++ b/apps/content-production-dashboard/test/locations/Home.spec.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { mockSdk } from '../mocks'; +import Home from '../../src/locations/Home'; +import { EntryProps } from 'contentful-management'; + +// Mock TanStack Query +const mockUseQuery = vi.fn(); +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: any) => mockUseQuery(options), +})); + +// Mock Contentful SDK +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, +})); + +describe('Home component', () => { + const mockRefetch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + }); + + it('shows error display when error exists', () => { + const testError = new Error('Failed to fetch entries'); + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: testError, + refetch: mockRefetch, + }); + + render(); + + expect(screen.getByText('Error loading entries')).toBeInTheDocument(); + expect(screen.getByText('Failed to fetch entries')).toBeInTheDocument(); + }); + + it('shows loading state when isFetching is true', () => { + const mockEntries: EntryProps[] = [ + { + sys: { id: 'entry-1', type: 'Entry' } as any, + fields: {}, + }, + ]; + + mockUseQuery.mockReturnValue({ + data: { + entries: mockEntries, + total: 1, + fetchedAt: new Date(), + }, + isLoading: false, + isFetching: true, + error: null, + refetch: mockRefetch, + }); + + render(); + + expect(screen.getByText('Loading component...')).toBeInTheDocument(); + }); +}); diff --git a/apps/content-production-dashboard/test/locations/Page.spec.tsx b/apps/content-production-dashboard/test/locations/Page.spec.tsx index 1ee8bb0d0b..f65bec7a1c 100644 --- a/apps/content-production-dashboard/test/locations/Page.spec.tsx +++ b/apps/content-production-dashboard/test/locations/Page.spec.tsx @@ -69,58 +69,4 @@ describe('Page component', () => { expect(screen.getByText('Loading component...')).toBeInTheDocument(); }); - - it('calls refetch when refresh button is clicked', () => { - const mockEntries: EntryProps[] = [ - { - sys: { id: 'entry-1', type: 'Entry' } as any, - fields: {}, - }, - ]; - - mockUseQuery.mockReturnValue({ - data: { - entries: mockEntries, - total: 1, - fetchedAt: new Date(), - }, - isLoading: false, - isFetching: false, - error: null, - refetch: mockRefetch, - }); - - render(); - - const refreshButton = screen.getByRole('button', { name: 'Refresh' }); - refreshButton.click(); - - expect(mockRefetch).toHaveBeenCalledTimes(1); - }); - - it('disables refresh button when isFetching is true', () => { - const mockEntries: EntryProps[] = [ - { - sys: { id: 'entry-1', type: 'Entry' } as any, - fields: {}, - }, - ]; - - mockUseQuery.mockReturnValue({ - data: { - entries: mockEntries, - total: 1, - fetchedAt: new Date(), - }, - isLoading: false, - isFetching: true, - error: null, - refetch: mockRefetch, - }); - - render(); - - const refreshButton = screen.getByRole('button', { name: 'Refreshing...' }); - expect(refreshButton).toBeDisabled(); - }); }); From 425b0cf83bdf3b2be47f59195d835a3b101027c9 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Thu, 18 Dec 2025 16:26:31 -0300 Subject: [PATCH 13/22] Removing unused refetch --- .../content-production-dashboard/src/components/Dashboard.tsx | 2 +- apps/content-production-dashboard/src/hooks/useAllEntries.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index b299548190..b71bc6ba6e 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -12,7 +12,7 @@ import { useAllEntries } from '../hooks/useAllEntries'; const Dashboard = () => { const sdk = useSDK(); const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; - const { entries, isFetching, error, refetch } = useAllEntries(); + const { entries, isFetching, error } = useAllEntries(); // TODO : replace this with the real scheduled actions. const scheduledActions: ScheduledActionProps[] = []; diff --git a/apps/content-production-dashboard/src/hooks/useAllEntries.ts b/apps/content-production-dashboard/src/hooks/useAllEntries.ts index 3603eb6c1b..db53f81e10 100644 --- a/apps/content-production-dashboard/src/hooks/useAllEntries.ts +++ b/apps/content-production-dashboard/src/hooks/useAllEntries.ts @@ -9,14 +9,13 @@ export interface UseAllEntriesResult { total: number; isFetching: boolean; error: Error | null; - refetch: () => void; fetchedAt: Date | undefined; } export function useAllEntries(): UseAllEntriesResult { const sdk = useSDK(); - const { data, isFetching, error, refetch } = useQuery({ + const { data, isFetching, error } = useQuery({ queryKey: ['entries', sdk.ids.space, sdk.ids.environment], queryFn: () => fetchAllEntries(sdk), }); @@ -26,7 +25,6 @@ export function useAllEntries(): UseAllEntriesResult { total: data?.total || 0, isFetching, error, - refetch, fetchedAt: data?.fetchedAt, }; } From 8dfc5a719d61e528e2aefedd5162b112b849b88a Mon Sep 17 00:00:00 2001 From: francobanfi Date: Mon, 5 Jan 2026 14:51:32 -0300 Subject: [PATCH 14/22] connecting the scheduled actions --- .../src/components/Dashboard.tsx | 15 +++-- .../src/hooks/useAllEntries.ts | 8 +-- .../src/hooks/useScheduledActions.ts | 60 +++++++++---------- .../test/components/Dashboard.spec.tsx | 16 ++++- 4 files changed, 55 insertions(+), 44 deletions(-) diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index b71bc6ba6e..73ac43a0a3 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -1,21 +1,20 @@ import { Flex, Box, Heading } from '@contentful/f36-components'; import { MetricCard } from './MetricCard'; import { MetricsCalculator } from '../metrics/MetricsCalculator'; -import { ScheduledActionProps } from 'contentful-management'; import { useSDK } from '@contentful/react-apps-toolkit'; import type { AppInstallationParameters } from '../locations/ConfigScreen'; import { styles } from '../locations/Page.styles'; import { ErrorDisplay } from './ErrorDisplay'; import { LoadingSkeleton } from './LoadingSkeleton'; import { useAllEntries } from '../hooks/useAllEntries'; +import { useScheduledActions } from '../hooks/useScheduledActions'; const Dashboard = () => { const sdk = useSDK(); const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; - const { entries, isFetching, error } = useAllEntries(); - - // TODO : replace this with the real scheduled actions. - const scheduledActions: ScheduledActionProps[] = []; + const { entries, isFetchingEntries, fetchingEntriesError } = useAllEntries(); + const { scheduledActions, isFetchingScheduledActions, fetchingScheduledActionsError } = + useScheduledActions(); const metrics = new MetricsCalculator(entries, scheduledActions, { needsUpdateMonths: installation.needsUpdateMonths, @@ -29,9 +28,9 @@ const Dashboard = () => { Content Dashboard - {error ? ( - - ) : isFetching ? ( + {fetchingEntriesError || fetchingScheduledActionsError ? ( + + ) : isFetchingEntries || isFetchingScheduledActions ? ( ) : ( <> diff --git a/apps/content-production-dashboard/src/hooks/useAllEntries.ts b/apps/content-production-dashboard/src/hooks/useAllEntries.ts index bedef329fd..a8932bc633 100644 --- a/apps/content-production-dashboard/src/hooks/useAllEntries.ts +++ b/apps/content-production-dashboard/src/hooks/useAllEntries.ts @@ -7,8 +7,8 @@ import { fetchAllEntries, FetchAllEntriesResult } from '../utils/fetchAllEntries export interface UseAllEntriesResult { entries: EntryProps[]; total: number; - isFetching: boolean; - error: Error | null; + isFetchingEntries: boolean; + fetchingEntriesError: Error | null; fetchedAt: Date | undefined; } @@ -23,8 +23,8 @@ export function useAllEntries(): UseAllEntriesResult { return { entries: data?.entries || [], total: data?.total || 0, - isFetching, - error, + isFetchingEntries: isFetching, + fetchingEntriesError: error, fetchedAt: data?.fetchedAt, }; } diff --git a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts index ede3580aa9..47c86bb146 100644 --- a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts +++ b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts @@ -1,32 +1,32 @@ -import { PageAppSDK } from "@contentful/app-sdk"; -import { useSDK } from "@contentful/react-apps-toolkit"; -import { useQuery } from "@tanstack/react-query"; -import { ScheduledActionProps } from "contentful-management"; -import { fetchScheduledActions, FetchScheduledActionsResult } from "../utils/fetchScheduledActions"; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { useQuery } from '@tanstack/react-query'; +import { ScheduledActionProps } from 'contentful-management'; +import { fetchScheduledActions, FetchScheduledActionsResult } from '../utils/fetchScheduledActions'; export interface UseScheduledActionsResult { - scheduledActions: ScheduledActionProps[]; - total: number; - isFetching: boolean; - error: Error | null; - refetch: () => void; - fetchedAt: Date | undefined; - } - - export function useScheduledActions(): UseScheduledActionsResult { - const sdk = useSDK(); - - const { data, isFetching, error, refetch } = useQuery({ - queryKey: ['scheduledActions', sdk.ids.space, sdk.ids.environment], - queryFn: () => fetchScheduledActions(sdk), - }); - - return { - scheduledActions: data?.scheduledActions || [], - total: data?.total || 0, - isFetching, - error, - refetch, - fetchedAt: data?.fetchedAt, - }; - } \ No newline at end of file + scheduledActions: ScheduledActionProps[]; + total: number; + isFetchingScheduledActions: boolean; + fetchingScheduledActionsError: Error | null; + refetch: () => void; + fetchedAt: Date | undefined; +} + +export function useScheduledActions(): UseScheduledActionsResult { + const sdk = useSDK(); + + const { data, isFetching, error, refetch } = useQuery({ + queryKey: ['scheduledActions', sdk.ids.space, sdk.ids.environment], + queryFn: () => fetchScheduledActions(sdk), + }); + + return { + scheduledActions: data?.scheduledActions || [], + total: data?.total || 0, + isFetchingScheduledActions: isFetching, + fetchingScheduledActionsError: error, + refetch, + fetchedAt: data?.fetchedAt, + }; +} diff --git a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx index 18bb45b914..ea52dab2e2 100644 --- a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx +++ b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx @@ -10,12 +10,24 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ })); const mockRefetch = vi.fn(); + vi.mock('../../src/hooks/useAllEntries', () => ({ useAllEntries: () => ({ entries: [], total: 0, - isFetching: false, - error: null, + isFetchingEntries: false, + fetchingEntriesError: null, + refetch: mockRefetch, + fetchedAt: new Date(), + }), +})); + +vi.mock('../../src/hooks/useScheduledActions', () => ({ + useScheduledActions: () => ({ + scheduledActions: [], + total: 0, + isFetchingScheduledActions: false, + fetchingScheduledActionsError: null, refetch: mockRefetch, fetchedAt: new Date(), }), From 71676812dd6ddb8c5e43c39d3f4b596c4fa38150 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Mon, 5 Jan 2026 15:07:21 -0300 Subject: [PATCH 15/22] fixing es-lint error --- .../src/scripts/generateEntries.ts | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/apps/content-production-dashboard/src/scripts/generateEntries.ts b/apps/content-production-dashboard/src/scripts/generateEntries.ts index 6ee1052dce..b38d72868c 100644 --- a/apps/content-production-dashboard/src/scripts/generateEntries.ts +++ b/apps/content-production-dashboard/src/scripts/generateEntries.ts @@ -86,7 +86,10 @@ const FIELD_TYPES = [ }, ]; -export async function createContentTypeWithAllFields(client: PlainClientAPI, contentTypeName: string) { +export async function createContentTypeWithAllFields( + client: PlainClientAPI, + contentTypeName: string +) { console.log(`Creating content type: ${contentTypeName}`); const fields = FIELD_TYPES.map((fieldType) => { @@ -189,41 +192,43 @@ export async function createSampleEntry( fields, }; - const { SPACE_ID, SCHEDULED_DATE, ENVIRONMENT_ID} = process.env; + const { SPACE_ID, SCHEDULED_DATE, ENVIRONMENT_ID } = process.env; try { const entryResult = await client.entry.create({ contentTypeId }, body); console.log(`✅ Created sample entry: ${entryResult.sys.id}`); - { SCHEDULED_DATE ? + if (SCHEDULED_DATE) { await client.scheduledActions.create( - { - spaceId: SPACE_ID ?? '', - }, - { - "entity": { - "sys": { - "type": "Link", - "linkType": "Entry", - "id": entryResult.sys.id - }, - }, - "environment": { - "sys": { - "type": "Link", - "linkType": "Environment", - "id": ENVIRONMENT_ID ?? '' - } - }, - "scheduledFor": { - "datetime": SCHEDULED_DATE ?? "2026-12-12T12:00:00.000Z", - "timezone": "UTC" + { + spaceId: SPACE_ID ?? '', }, - "action": "publish" - } - ) : await client.entry.publish({ entryId: entryResult.sys.id }, entryResult) } + { + entity: { + sys: { + type: 'Link', + linkType: 'Entry', + id: entryResult.sys.id, + }, + }, + environment: { + sys: { + type: 'Link', + linkType: 'Environment', + id: ENVIRONMENT_ID ?? '', + }, + }, + scheduledFor: { + datetime: SCHEDULED_DATE ?? '2026-12-12T12:00:00.000Z', + timezone: 'UTC', + }, + action: 'publish', + } + ); + } else { + await client.entry.publish({ entryId: entryResult.sys.id }, entryResult); + } - console.log(`Sample entry ${index + 1}`); return entryResult.sys.id; @@ -293,7 +298,9 @@ export async function generateEntries() { const rl = createReadlineInterface(); const { AMOUNT_OF_ENTRIES, SCHEDULED_DATE } = process.env; - const contentTypeName = SCHEDULED_DATE ? 'Scheduled - All Field Types': 'Publish - All Field Types'; + const contentTypeName = SCHEDULED_DATE + ? 'Scheduled - All Field Types' + : 'Publish - All Field Types'; const contentTypeId = await createContentTypeWithAllFields(client, contentTypeName); From a23901de5b06e41d9089c01ed3be6ec32e0208d0 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Mon, 5 Jan 2026 15:14:19 -0300 Subject: [PATCH 16/22] prettier fix --- .../src/utils/fetchScheduledActions.ts | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts b/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts index a6e4a88346..f2a70045bd 100644 --- a/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts +++ b/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts @@ -1,29 +1,30 @@ -import { PageAppSDK } from "@contentful/app-sdk"; -import { ScheduledActionProps } from "contentful-management"; - +import { PageAppSDK } from '@contentful/app-sdk'; +import { ScheduledActionProps } from 'contentful-management'; export interface FetchScheduledActionsResult { - scheduledActions: ScheduledActionProps[]; - total: number; - fetchedAt: Date; - } + scheduledActions: ScheduledActionProps[]; + total: number; + fetchedAt: Date; +} // The current limit of scheduled actions in scheduled status is 500. Once it's reached, no additional scheduled actions can be created. -export const fetchScheduledActions = async (sdk: PageAppSDK): Promise => { - const scheduledActions = await sdk.cma.scheduledActions.getMany({ - spaceId: sdk.ids.space, - query: { - 'environment.sys.id': sdk.ids.environment, - 'sys.status[in]': 'scheduled', - 'order': 'scheduledFor.datetime', - 'limit': 500 - } - }); - - return { - scheduledActions: scheduledActions.items, - total: scheduledActions.items.length, - fetchedAt: new Date(), - }; -}; \ No newline at end of file +export const fetchScheduledActions = async ( + sdk: PageAppSDK +): Promise => { + const scheduledActions = await sdk.cma.scheduledActions.getMany({ + spaceId: sdk.ids.space, + query: { + 'environment.sys.id': sdk.ids.environment, + 'sys.status[in]': 'scheduled', + order: 'scheduledFor.datetime', + limit: 500, + }, + }); + + return { + scheduledActions: scheduledActions.items, + total: scheduledActions.items.length, + fetchedAt: new Date(), + }; +}; From 077b0e503693b31bd96407a7bd770c50a32fcd9b Mon Sep 17 00:00:00 2001 From: francobanfi Date: Mon, 5 Jan 2026 16:50:42 -0300 Subject: [PATCH 17/22] removing status check --- .../src/metrics/MetricsCalculator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index 844439fef6..4e306974c3 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -118,7 +118,6 @@ export class MetricsCalculator { let count = 0; for (const action of this.scheduledActions) { - if (action?.sys?.status && action.sys.status !== 'scheduled') continue; const scheduledFor = parseDate(action?.scheduledFor?.datetime); if (!scheduledFor) continue; if (isWithin(scheduledFor, this.now, end)) { From 282e45559056f96b2a4b1b527ccaec48fa262050 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Mon, 5 Jan 2026 16:53:14 -0300 Subject: [PATCH 18/22] using setDate --- apps/content-production-dashboard/src/utils/dates.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/content-production-dashboard/src/utils/dates.ts b/apps/content-production-dashboard/src/utils/dates.ts index 0b24e609f5..8ca3de22c7 100644 --- a/apps/content-production-dashboard/src/utils/dates.ts +++ b/apps/content-production-dashboard/src/utils/dates.ts @@ -8,12 +8,14 @@ export function parseDate(value: string | undefined): MaybeDate { return Number.isNaN(ms) ? undefined : new Date(ms); } -export function addDays(base: Date, days: number): Date { - return new Date(base.getTime() + days * msPerDay); +export function addDays(date: Date, days: number): Date { + date.setDate(date.getDate() + days); + return date; } -export function subDays(base: Date, days: number): Date { - return new Date(base.getTime() - days * msPerDay); +export function subDays(date: Date, days: number): Date { + date.setDate(date.getDate() - days); + return date; } export function subMonths(base: Date, months: number): Date { From f19e634765ccdb27cc9185144e6fcd48d1b45dab Mon Sep 17 00:00:00 2001 From: francobanfi Date: Mon, 5 Jan 2026 16:58:47 -0300 Subject: [PATCH 19/22] removing metrics file and merging it to MetricsCalculator --- .../src/metrics/MetricsCalculator.ts | 12 +++++++++++- .../src/utils/metrics.ts | 13 ------------- 2 files changed, 11 insertions(+), 14 deletions(-) delete mode 100644 apps/content-production-dashboard/src/utils/metrics.ts diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index 4e306974c3..7a3e28dba0 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -10,7 +10,17 @@ import { TIME_TO_PUBLISH_DAYS_RANGE, } from '../utils/consts'; import { msPerDay, parseDate, addDays, subDays, subMonths, isWithin } from '../utils/dates'; -import { percentChange } from '../utils/metrics'; + +function percentChange(current: number, previous: number): { text: string; isNegative: boolean } { + if (previous === 0) { + if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; + return { text: 'New publishing this month', isNegative: false }; + } + const pct = ((current - previous) / previous) * 100; + const abs = Math.abs(pct).toFixed(1); + const direction = pct < 0 ? 'decrease' : 'increase'; + return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; +} export class MetricsCalculator { private readonly entries: ReadonlyArray; diff --git a/apps/content-production-dashboard/src/utils/metrics.ts b/apps/content-production-dashboard/src/utils/metrics.ts deleted file mode 100644 index 132f6b291e..0000000000 --- a/apps/content-production-dashboard/src/utils/metrics.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function percentChange( - current: number, - previous: number -): { text: string; isNegative: boolean } { - if (previous === 0) { - if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; - return { text: 'New publishing this month', isNegative: false }; - } - const pct = ((current - previous) / previous) * 100; - const abs = Math.abs(pct).toFixed(1); - const direction = pct < 0 ? 'decrease' : 'increase'; - return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; -} From 5db9da1a172f46fd40b4e779bdb0d58785233cfa Mon Sep 17 00:00:00 2001 From: francobanfi Date: Mon, 5 Jan 2026 17:45:13 -0300 Subject: [PATCH 20/22] adding date calculator and refactors in metrics calculator --- .../src/components/Dashboard.tsx | 5 +- .../src/metrics/MetricsCalculator.ts | 44 ++++++------- .../src/utils/DateCalculator.ts | 33 ++++++++++ .../src/utils/dates.ts | 29 --------- .../test/metrics/MetricsCalculator.spec.ts | 62 +++++++------------ 5 files changed, 80 insertions(+), 93 deletions(-) create mode 100644 apps/content-production-dashboard/src/utils/DateCalculator.ts delete mode 100644 apps/content-production-dashboard/src/utils/dates.ts diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx index 73ac43a0a3..ae112e7f06 100644 --- a/apps/content-production-dashboard/src/components/Dashboard.tsx +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -16,11 +16,12 @@ const Dashboard = () => { const { scheduledActions, isFetchingScheduledActions, fetchingScheduledActionsError } = useScheduledActions(); - const metrics = new MetricsCalculator(entries, scheduledActions, { + const metricsCalculator = new MetricsCalculator(entries, scheduledActions, { needsUpdateMonths: installation.needsUpdateMonths, recentlyPublishedDays: installation.recentlyPublishedDays, timeToPublishDays: installation.timeToPublishDays, - }).metrics; + }); + const metrics = metricsCalculator.getAllMetrics(); return ( diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index 7a3e28dba0..8f75bde083 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -9,7 +9,7 @@ import { RECENTLY_PUBLISHED_DAYS_RANGE, TIME_TO_PUBLISH_DAYS_RANGE, } from '../utils/consts'; -import { msPerDay, parseDate, addDays, subDays, subMonths, isWithin } from '../utils/dates'; +import { DateCalculator, msPerDay } from '../utils/DateCalculator'; function percentChange(current: number, previous: number): { text: string; isNegative: boolean } { if (previous === 0) { @@ -29,8 +29,7 @@ export class MetricsCalculator { private readonly needsUpdateMonths: number; private readonly recentlyPublishedDays: number; private readonly timeToPublishDays: number; - - public readonly metrics: ReadonlyArray; + private readonly dateCalculator: DateCalculator; constructor( entries: ReadonlyArray, @@ -48,8 +47,11 @@ export class MetricsCalculator { this.recentlyPublishedDays = options?.recentlyPublishedDays ?? RECENTLY_PUBLISHED_DAYS_RANGE.min; this.timeToPublishDays = options?.timeToPublishDays ?? TIME_TO_PUBLISH_DAYS_RANGE.min; + this.dateCalculator = new DateCalculator(); + } - this.metrics = [ + public getAllMetrics(): ReadonlyArray { + return [ this.calculateTotalPublished(), this.calculateAverageTimeToPublish(), this.calculateScheduled(), @@ -59,21 +61,21 @@ export class MetricsCalculator { } private calculateTotalPublished(): MetricCardProps { - const startThisPeriod = subDays(this.now, 30); - const startPrevPeriod = subDays(this.now, 60); + const startThisPeriod = this.dateCalculator.subDays(this.now, 30); + const startPrevPeriod = this.dateCalculator.subDays(this.now, 60); const endPrevPeriod = startThisPeriod; let current = 0; let previous = 0; for (const entry of this.entries) { - const publishedAt = parseDate(entry?.sys?.publishedAt); + const publishedAt = this.dateCalculator.parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; - if (isWithin(publishedAt, startThisPeriod, this.now)) { + if (this.dateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) { current += 1; continue; } - if (isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) { + if (this.dateCalculator.isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) { previous += 1; } } @@ -90,16 +92,16 @@ export class MetricsCalculator { } private calculateAverageTimeToPublish(): MetricCardProps { - const startThisPeriod = subDays(this.now, this.timeToPublishDays); + const startThisPeriod = this.dateCalculator.subDays(this.now, this.timeToPublishDays); let sumDays = 0; let count = 0; for (const entry of this.entries) { - const publishedAt = parseDate(entry?.sys?.publishedAt); + const publishedAt = this.dateCalculator.parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; - if (!isWithin(publishedAt, startThisPeriod, this.now)) continue; + if (!this.dateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) continue; - const createdAt = parseDate(entry?.sys?.createdAt); + const createdAt = this.dateCalculator.parseDate(entry?.sys?.createdAt); if (!createdAt) continue; const deltaDays = (publishedAt.getTime() - createdAt.getTime()) / msPerDay; @@ -124,13 +126,13 @@ export class MetricsCalculator { } private calculateScheduled(): MetricCardProps { - const end = addDays(this.now, 30); + const end = this.dateCalculator.addDays(this.now, 30); let count = 0; for (const action of this.scheduledActions) { - const scheduledFor = parseDate(action?.scheduledFor?.datetime); + const scheduledFor = this.dateCalculator.parseDate(action?.scheduledFor?.datetime); if (!scheduledFor) continue; - if (isWithin(scheduledFor, this.now, end)) { + if (this.dateCalculator.isWithin(scheduledFor, this.now, end)) { count += 1; } } @@ -145,13 +147,13 @@ export class MetricsCalculator { } private calculateRecentlyPublished(): MetricCardProps { - const start = subDays(this.now, this.recentlyPublishedDays); + const start = this.dateCalculator.subDays(this.now, this.recentlyPublishedDays); let count = 0; for (const entry of this.entries) { - const publishedAt = parseDate(entry?.sys?.publishedAt); + const publishedAt = this.dateCalculator.parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; - if (isWithin(publishedAt, start, this.now)) { + if (this.dateCalculator.isWithin(publishedAt, start, this.now)) { count += 1; } } @@ -166,11 +168,11 @@ export class MetricsCalculator { } private calculateNeedsUpdate(): MetricCardProps { - const cutoff = subMonths(this.now, this.needsUpdateMonths); + const cutoff = this.dateCalculator.subMonths(this.now, this.needsUpdateMonths); let count = 0; for (const entry of this.entries) { - const updatedAt = parseDate(entry?.sys?.updatedAt); + const updatedAt = this.dateCalculator.parseDate(entry?.sys?.updatedAt); if (!updatedAt) continue; if (updatedAt.getTime() < cutoff.getTime()) { count += 1; diff --git a/apps/content-production-dashboard/src/utils/DateCalculator.ts b/apps/content-production-dashboard/src/utils/DateCalculator.ts new file mode 100644 index 0000000000..a776dc5030 --- /dev/null +++ b/apps/content-production-dashboard/src/utils/DateCalculator.ts @@ -0,0 +1,33 @@ +export const msPerDay = 24 * 60 * 60 * 1000; + +export type MaybeDate = Date | undefined; + +export class DateCalculator { + parseDate(value: string | undefined): MaybeDate { + if (!value) return undefined; + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : new Date(ms); + } + + addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } + + subDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() - days); + return result; + } + + subMonths(base: Date, months: number): Date { + const date = new Date(base); + date.setMonth(date.getMonth() - months); + return date; + } + + isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { + return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); + } +} diff --git a/apps/content-production-dashboard/src/utils/dates.ts b/apps/content-production-dashboard/src/utils/dates.ts deleted file mode 100644 index 8ca3de22c7..0000000000 --- a/apps/content-production-dashboard/src/utils/dates.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const msPerDay = 24 * 60 * 60 * 1000; - -export type MaybeDate = Date | undefined; - -export function parseDate(value: string | undefined): MaybeDate { - if (!value) return undefined; - const ms = Date.parse(value); - return Number.isNaN(ms) ? undefined : new Date(ms); -} - -export function addDays(date: Date, days: number): Date { - date.setDate(date.getDate() + days); - return date; -} - -export function subDays(date: Date, days: number): Date { - date.setDate(date.getDate() - days); - return date; -} - -export function subMonths(base: Date, months: number): Date { - const date = new Date(base); - date.setMonth(date.getMonth() - months); - return date; -} - -export function isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { - return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); -} diff --git a/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts index e7b63f71c2..afb6751f3f 100644 --- a/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts +++ b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts @@ -19,16 +19,15 @@ describe('MetricsCalculator', () => { it('initializes with empty arrays', () => { const calculator = new MetricsCalculator([], []); - expect(calculator.metrics).toHaveLength(5); + expect(calculator.getAllMetrics()).toHaveLength(5); }); it('uses default values when options are not provided', () => { const calculator = new MetricsCalculator([], []); - const needsUpdateMetric = calculator.metrics.find((m) => m.title === 'Needs Update'); - const recentlyPublishedMetric = calculator.metrics.find( - (m) => m.title === 'Recently Published' - ); - const avgTimeMetric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + const metrics = calculator.getAllMetrics(); + const needsUpdateMetric = metrics.find((m) => m.title === 'Needs Update'); + const recentlyPublishedMetric = metrics.find((m) => m.title === 'Recently Published'); + const avgTimeMetric = metrics.find((m) => m.title === 'Average Time to Publish'); expect(needsUpdateMetric?.subtitle).toContain(`${NEEDS_UPDATE_MONTHS_RANGE.min} months`); expect(recentlyPublishedMetric?.subtitle).toContain( @@ -43,11 +42,10 @@ describe('MetricsCalculator', () => { recentlyPublishedDays: 14, timeToPublishDays: 60, }); - const needsUpdateMetric = calculator.metrics.find((m) => m.title === 'Needs Update'); - const recentlyPublishedMetric = calculator.metrics.find( - (m) => m.title === 'Recently Published' - ); - const avgTimeMetric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + const metrics = calculator.getAllMetrics(); + const needsUpdateMetric = metrics.find((m) => m.title === 'Needs Update'); + const recentlyPublishedMetric = metrics.find((m) => m.title === 'Recently Published'); + const avgTimeMetric = metrics.find((m) => m.title === 'Average Time to Publish'); expect(needsUpdateMetric?.subtitle).toContain('12 months'); expect(recentlyPublishedMetric?.subtitle).toContain('14 days'); @@ -64,7 +62,7 @@ describe('MetricsCalculator', () => { ]; const calculator = new MetricsCalculator(entries, []); - const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); expect(metric?.value).toBe('2'); }); @@ -76,7 +74,7 @@ describe('MetricsCalculator', () => { ]; const calculator = new MetricsCalculator(entries, []); - const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); expect(metric?.subtitle).toContain('% publishing'); }); @@ -85,7 +83,7 @@ describe('MetricsCalculator', () => { const entries: EntryProps[] = [{ sys: { publishedAt: daysAgo(10) } } as EntryProps]; const calculator = new MetricsCalculator(entries, []); - const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); expect(metric?.subtitle).toContain('New publishing this month'); }); @@ -96,7 +94,7 @@ describe('MetricsCalculator', () => { ]; const calculator = new MetricsCalculator(entries, []); - const metric = calculator.metrics.find((m) => m.title === 'Total Published'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); expect(metric?.value).toBe('0'); expect(metric?.subtitle).toContain('0.0% publishing change MoM'); @@ -123,7 +121,7 @@ describe('MetricsCalculator', () => { const calculator = new MetricsCalculator(entries, [], { timeToPublishDays: 30, }); - const metric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Average Time to Publish'); expect(metric?.value).toBe('7.5 days'); }); @@ -141,7 +139,7 @@ describe('MetricsCalculator', () => { const calculator = new MetricsCalculator(entries, [], { timeToPublishDays: 30, }); - const metric = calculator.metrics.find((m) => m.title === 'Average Time to Publish'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Average Time to Publish'); expect(metric?.value).toBe('—'); expect(metric?.subtitle).toContain('No entries published'); @@ -166,28 +164,10 @@ describe('MetricsCalculator', () => { ]; const calculator = new MetricsCalculator([], scheduledActions); - const metric = calculator.metrics.find((m) => m.title === 'Scheduled'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Scheduled'); expect(metric?.value).toBe('2'); }); - - it('ignores non-scheduled actions', () => { - const scheduledActions: ScheduledActionProps[] = [ - { - scheduledFor: { datetime: daysFromNow(5), timezone: 'UTC' }, - sys: { status: ScheduledActionStatus.scheduled }, - } as ScheduledActionProps, - { - scheduledFor: { datetime: daysFromNow(10), timezone: 'UTC' }, - sys: { status: 'cancelled' }, - } as unknown as ScheduledActionProps, - ]; - - const calculator = new MetricsCalculator([], scheduledActions); - const metric = calculator.metrics.find((m) => m.title === 'Scheduled'); - - expect(metric?.value).toBe('1'); - }); }); describe('calculateRecentlyPublished', () => { @@ -201,7 +181,7 @@ describe('MetricsCalculator', () => { const calculator = new MetricsCalculator(entries, [], { recentlyPublishedDays: 7, }); - const metric = calculator.metrics.find((m) => m.title === 'Recently Published'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Recently Published'); expect(metric?.value).toBe('2'); expect(metric?.subtitle).toContain('7 days'); @@ -215,7 +195,7 @@ describe('MetricsCalculator', () => { const calculator = new MetricsCalculator(entries, [], { recentlyPublishedDays: 14, }); - const metric = calculator.metrics.find((m) => m.title === 'Recently Published'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Recently Published'); expect(metric?.value).toBe('1'); expect(metric?.subtitle).toContain('14 days'); @@ -233,7 +213,7 @@ describe('MetricsCalculator', () => { const calculator = new MetricsCalculator(entries, [], { needsUpdateMonths: 6, }); - const metric = calculator.metrics.find((m) => m.title === 'Needs Update'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Needs Update'); expect(metric?.value).toBe('2'); expect(metric?.subtitle).toContain('6 months'); @@ -247,7 +227,7 @@ describe('MetricsCalculator', () => { const calculator = new MetricsCalculator(entries, [], { needsUpdateMonths: 12, }); - const metric = calculator.metrics.find((m) => m.title === 'Needs Update'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Needs Update'); expect(metric?.value).toBe('1'); expect(metric?.subtitle).toContain('12 months'); @@ -262,7 +242,7 @@ describe('MetricsCalculator', () => { const calculator = new MetricsCalculator(entries, [], { needsUpdateMonths: 6, }); - const metric = calculator.metrics.find((m) => m.title === 'Needs Update'); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Needs Update'); expect(metric?.value).toBe('1'); }); From 631fdd968be1a2de97fff92ae0a53745f2dbc242 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Tue, 6 Jan 2026 10:14:26 -0300 Subject: [PATCH 21/22] refactor in DateCalculator and MetricsCalculator --- .../src/metrics/MetricsCalculator.ts | 60 +++++++++---------- .../src/utils/DateCalculator.ts | 10 ++-- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index 8f75bde083..aa2e805cb4 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -11,17 +11,6 @@ import { } from '../utils/consts'; import { DateCalculator, msPerDay } from '../utils/DateCalculator'; -function percentChange(current: number, previous: number): { text: string; isNegative: boolean } { - if (previous === 0) { - if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; - return { text: 'New publishing this month', isNegative: false }; - } - const pct = ((current - previous) / previous) * 100; - const abs = Math.abs(pct).toFixed(1); - const direction = pct < 0 ? 'decrease' : 'increase'; - return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; -} - export class MetricsCalculator { private readonly entries: ReadonlyArray; private readonly scheduledActions: ReadonlyArray; @@ -29,7 +18,6 @@ export class MetricsCalculator { private readonly needsUpdateMonths: number; private readonly recentlyPublishedDays: number; private readonly timeToPublishDays: number; - private readonly dateCalculator: DateCalculator; constructor( entries: ReadonlyArray, @@ -47,7 +35,6 @@ export class MetricsCalculator { this.recentlyPublishedDays = options?.recentlyPublishedDays ?? RECENTLY_PUBLISHED_DAYS_RANGE.min; this.timeToPublishDays = options?.timeToPublishDays ?? TIME_TO_PUBLISH_DAYS_RANGE.min; - this.dateCalculator = new DateCalculator(); } public getAllMetrics(): ReadonlyArray { @@ -60,27 +47,38 @@ export class MetricsCalculator { ]; } + private percentChange(current: number, previous: number): { text: string; isNegative: boolean } { + if (previous === 0) { + if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; + return { text: 'New publishing this month', isNegative: false }; + } + const pct = ((current - previous) / previous) * 100; + const abs = Math.abs(pct).toFixed(1); + const direction = pct < 0 ? 'decrease' : 'increase'; + return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; + } + private calculateTotalPublished(): MetricCardProps { - const startThisPeriod = this.dateCalculator.subDays(this.now, 30); - const startPrevPeriod = this.dateCalculator.subDays(this.now, 60); + const startThisPeriod = DateCalculator.subDays(this.now, 30); + const startPrevPeriod = DateCalculator.subDays(this.now, 60); const endPrevPeriod = startThisPeriod; let current = 0; let previous = 0; for (const entry of this.entries) { - const publishedAt = this.dateCalculator.parseDate(entry?.sys?.publishedAt); + const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; - if (this.dateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) { + if (DateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) { current += 1; continue; } - if (this.dateCalculator.isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) { + if (DateCalculator.isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) { previous += 1; } } - const { text, isNegative } = percentChange(current, previous); + const { text, isNegative } = this.percentChange(current, previous); return { title: 'Total Published', @@ -92,16 +90,16 @@ export class MetricsCalculator { } private calculateAverageTimeToPublish(): MetricCardProps { - const startThisPeriod = this.dateCalculator.subDays(this.now, this.timeToPublishDays); + const startThisPeriod = DateCalculator.subDays(this.now, this.timeToPublishDays); let sumDays = 0; let count = 0; for (const entry of this.entries) { - const publishedAt = this.dateCalculator.parseDate(entry?.sys?.publishedAt); + const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; - if (!this.dateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) continue; + if (!DateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) continue; - const createdAt = this.dateCalculator.parseDate(entry?.sys?.createdAt); + const createdAt = DateCalculator.parseDate(entry?.sys?.createdAt); if (!createdAt) continue; const deltaDays = (publishedAt.getTime() - createdAt.getTime()) / msPerDay; @@ -126,13 +124,13 @@ export class MetricsCalculator { } private calculateScheduled(): MetricCardProps { - const end = this.dateCalculator.addDays(this.now, 30); + const end = DateCalculator.addDays(this.now, 30); let count = 0; for (const action of this.scheduledActions) { - const scheduledFor = this.dateCalculator.parseDate(action?.scheduledFor?.datetime); + const scheduledFor = DateCalculator.parseDate(action?.scheduledFor?.datetime); if (!scheduledFor) continue; - if (this.dateCalculator.isWithin(scheduledFor, this.now, end)) { + if (DateCalculator.isWithin(scheduledFor, this.now, end)) { count += 1; } } @@ -147,13 +145,13 @@ export class MetricsCalculator { } private calculateRecentlyPublished(): MetricCardProps { - const start = this.dateCalculator.subDays(this.now, this.recentlyPublishedDays); + const start = DateCalculator.subDays(this.now, this.recentlyPublishedDays); let count = 0; for (const entry of this.entries) { - const publishedAt = this.dateCalculator.parseDate(entry?.sys?.publishedAt); + const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt); if (!publishedAt) continue; - if (this.dateCalculator.isWithin(publishedAt, start, this.now)) { + if (DateCalculator.isWithin(publishedAt, start, this.now)) { count += 1; } } @@ -168,11 +166,11 @@ export class MetricsCalculator { } private calculateNeedsUpdate(): MetricCardProps { - const cutoff = this.dateCalculator.subMonths(this.now, this.needsUpdateMonths); + const cutoff = DateCalculator.subMonths(this.now, this.needsUpdateMonths); let count = 0; for (const entry of this.entries) { - const updatedAt = this.dateCalculator.parseDate(entry?.sys?.updatedAt); + const updatedAt = DateCalculator.parseDate(entry?.sys?.updatedAt); if (!updatedAt) continue; if (updatedAt.getTime() < cutoff.getTime()) { count += 1; diff --git a/apps/content-production-dashboard/src/utils/DateCalculator.ts b/apps/content-production-dashboard/src/utils/DateCalculator.ts index a776dc5030..2c8a193b5f 100644 --- a/apps/content-production-dashboard/src/utils/DateCalculator.ts +++ b/apps/content-production-dashboard/src/utils/DateCalculator.ts @@ -3,31 +3,31 @@ export const msPerDay = 24 * 60 * 60 * 1000; export type MaybeDate = Date | undefined; export class DateCalculator { - parseDate(value: string | undefined): MaybeDate { + static parseDate(value: string | undefined): MaybeDate { if (!value) return undefined; const ms = Date.parse(value); return Number.isNaN(ms) ? undefined : new Date(ms); } - addDays(date: Date, days: number): Date { + static addDays(date: Date, days: number): Date { const result = new Date(date); result.setDate(result.getDate() + days); return result; } - subDays(date: Date, days: number): Date { + static subDays(date: Date, days: number): Date { const result = new Date(date); result.setDate(result.getDate() - days); return result; } - subMonths(base: Date, months: number): Date { + static subMonths(base: Date, months: number): Date { const date = new Date(base); date.setMonth(date.getMonth() - months); return date; } - isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { + static isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); } } From 426644aa7d32f10d4c0b68501123e8992f3d81a1 Mon Sep 17 00:00:00 2001 From: francobanfi Date: Tue, 6 Jan 2026 12:24:19 -0300 Subject: [PATCH 22/22] refactors in MetricsCalculator and useScheduledActions --- .../src/hooks/useScheduledActions.ts | 4 +--- .../src/metrics/MetricsCalculator.ts | 7 +++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts index 47c86bb146..fef64ef3bb 100644 --- a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts +++ b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts @@ -9,14 +9,13 @@ export interface UseScheduledActionsResult { total: number; isFetchingScheduledActions: boolean; fetchingScheduledActionsError: Error | null; - refetch: () => void; fetchedAt: Date | undefined; } export function useScheduledActions(): UseScheduledActionsResult { const sdk = useSDK(); - const { data, isFetching, error, refetch } = useQuery({ + const { data, isFetching, error } = useQuery({ queryKey: ['scheduledActions', sdk.ids.space, sdk.ids.environment], queryFn: () => fetchScheduledActions(sdk), }); @@ -26,7 +25,6 @@ export function useScheduledActions(): UseScheduledActionsResult { total: data?.total || 0, isFetchingScheduledActions: isFetching, fetchingScheduledActionsError: error, - refetch, fetchedAt: data?.fetchedAt, }; } diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts index aa2e805cb4..94a97b0d01 100644 --- a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -47,7 +47,10 @@ export class MetricsCalculator { ]; } - private percentChange(current: number, previous: number): { text: string; isNegative: boolean } { + private calculatePublishingChangeText( + current: number, + previous: number + ): { text: string; isNegative: boolean } { if (previous === 0) { if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; return { text: 'New publishing this month', isNegative: false }; @@ -78,7 +81,7 @@ export class MetricsCalculator { } } - const { text, isNegative } = this.percentChange(current, previous); + const { text, isNegative } = this.calculatePublishingChangeText(current, previous); return { title: 'Total Published',