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..ae112e7f06
--- /dev/null
+++ b/apps/content-production-dashboard/src/components/Dashboard.tsx
@@ -0,0 +1,58 @@
+import { Flex, Box, Heading } from '@contentful/f36-components';
+import { MetricCard } from './MetricCard';
+import { MetricsCalculator } from '../metrics/MetricsCalculator';
+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, isFetchingEntries, fetchingEntriesError } = useAllEntries();
+ const { scheduledActions, isFetchingScheduledActions, fetchingScheduledActionsError } =
+ useScheduledActions();
+
+ const metricsCalculator = new MetricsCalculator(entries, scheduledActions, {
+ needsUpdateMonths: installation.needsUpdateMonths,
+ recentlyPublishedDays: installation.recentlyPublishedDays,
+ timeToPublishDays: installation.timeToPublishDays,
+ });
+ const metrics = metricsCalculator.getAllMetrics();
+
+ return (
+
+
+ Content Dashboard
+
+
+ {fetchingEntriesError || fetchingScheduledActionsError ? (
+
+ ) : isFetchingEntries || isFetchingScheduledActions ? (
+
+ ) : (
+ <>
+
+ {metrics.map((metric) => {
+ return (
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+};
+
+export default Dashboard;
diff --git a/apps/content-production-dashboard/src/components/MetricCard.styles.ts b/apps/content-production-dashboard/src/components/MetricCard.styles.ts
new file mode 100644
index 0000000000..3e9ec85b06
--- /dev/null
+++ b/apps/content-production-dashboard/src/components/MetricCard.styles.ts
@@ -0,0 +1,10 @@
+import tokens from '@contentful/f36-tokens';
+import { CSSProperties } from 'react';
+
+export const styles = {
+ card: {
+ border: `1px solid ${tokens.gray300}`,
+ borderRadius: '4px',
+ boxShadow: 'none',
+ } as CSSProperties,
+};
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..ce8131d3d4
--- /dev/null
+++ b/apps/content-production-dashboard/src/components/MetricCard.tsx
@@ -0,0 +1,34 @@
+import { Card, Flex, Text } from '@contentful/f36-components';
+import type { IconProps } from '@contentful/f36-icons';
+import type { ComponentType } from 'react';
+import tokens from '@contentful/f36-tokens';
+import { styles } from './MetricCard.styles';
+
+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/hooks/useAllEntries.ts b/apps/content-production-dashboard/src/hooks/useAllEntries.ts
index fed287e811..a8932bc633 100644
--- a/apps/content-production-dashboard/src/hooks/useAllEntries.ts
+++ b/apps/content-production-dashboard/src/hooks/useAllEntries.ts
@@ -7,16 +7,15 @@ import { fetchAllEntries, FetchAllEntriesResult } from '../utils/fetchAllEntries
export interface UseAllEntriesResult {
entries: EntryProps[];
total: number;
- isFetching: boolean;
- error: Error | null;
- refetch: () => void;
+ isFetchingEntries: boolean;
+ fetchingEntriesError: Error | null;
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),
});
@@ -24,9 +23,8 @@ export function useAllEntries(): UseAllEntriesResult {
return {
entries: data?.entries || [],
total: data?.total || 0,
- isFetching,
- error,
- refetch,
+ 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..fef64ef3bb 100644
--- a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts
+++ b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts
@@ -1,32 +1,30 @@
-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;
+ fetchedAt: Date | undefined;
+}
+
+export function useScheduledActions(): UseScheduledActionsResult {
+ const sdk = useSDK();
+
+ const { data, isFetching, error } = useQuery({
+ queryKey: ['scheduledActions', sdk.ids.space, sdk.ids.environment],
+ queryFn: () => fetchScheduledActions(sdk),
+ });
+
+ return {
+ scheduledActions: data?.scheduledActions || [],
+ total: data?.total || 0,
+ isFetchingScheduledActions: isFetching,
+ fetchingScheduledActionsError: error,
+ fetchedAt: data?.fetchedAt,
+ };
+}
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.styles.ts b/apps/content-production-dashboard/src/locations/Page.styles.ts
index af6f6f60ad..0f2c2a2e46 100644
--- a/apps/content-production-dashboard/src/locations/Page.styles.ts
+++ b/apps/content-production-dashboard/src/locations/Page.styles.ts
@@ -1,9 +1,9 @@
import tokens from '@contentful/f36-tokens';
-import { css } from 'emotion';
+import { CSSProperties } from 'react';
export const styles = {
- pageContainer: css({
+ container: {
+ padding: tokens.spacingL,
backgroundColor: tokens.colorWhite,
- minHeight: '100vh',
- }),
+ } as CSSProperties,
};
diff --git a/apps/content-production-dashboard/src/locations/Page.tsx b/apps/content-production-dashboard/src/locations/Page.tsx
index 3b609eb713..836647b7ef 100644
--- a/apps/content-production-dashboard/src/locations/Page.tsx
+++ b/apps/content-production-dashboard/src/locations/Page.tsx
@@ -1,30 +1,7 @@
-import { Box, Button, Flex, Heading } from '@contentful/f36-components';
-import { useAllEntries } from '../hooks/useAllEntries';
-import { LoadingSkeleton } from '../components/LoadingSkeleton';
-import { ErrorDisplay } from '../components/ErrorDisplay';
-import { styles } from './Page.styles';
+import Dashboard from '../components/Dashboard';
const Page = () => {
- const { isFetching, error, refetch } = useAllEntries();
-
- return (
-
-
- Content Dashboard
-
-
-
- {error ? (
-
- ) : isFetching ? (
-
- ) : (
- <>{/* TODO: implement the rest of the sections */}>
- )}
-
- );
+ 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..94a97b0d01
--- /dev/null
+++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts
@@ -0,0 +1,191 @@
+import { FileIcon } from '@contentful/f36-icons';
+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';
+import {
+ NEEDS_UPDATE_MONTHS_RANGE,
+ RECENTLY_PUBLISHED_DAYS_RANGE,
+ TIME_TO_PUBLISH_DAYS_RANGE,
+} from '../utils/consts';
+import { DateCalculator, msPerDay } from '../utils/DateCalculator';
+
+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;
+
+ constructor(
+ entries: 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;
+ }
+
+ public getAllMetrics(): ReadonlyArray {
+ return [
+ this.calculateTotalPublished(),
+ this.calculateAverageTimeToPublish(),
+ this.calculateScheduled(),
+ this.calculateRecentlyPublished(),
+ this.calculateNeedsUpdate(),
+ ];
+ }
+
+ 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 };
+ }
+ 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 = 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 = DateCalculator.parseDate(entry?.sys?.publishedAt);
+ if (!publishedAt) continue;
+
+ if (DateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) {
+ current += 1;
+ continue;
+ }
+ if (DateCalculator.isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) {
+ previous += 1;
+ }
+ }
+
+ const { text, isNegative } = this.calculatePublishingChangeText(current, previous);
+
+ return {
+ title: 'Total Published',
+ value: String(current),
+ subtitle: text,
+ icon: FileIcon,
+ isNegative,
+ };
+ }
+
+ private calculateAverageTimeToPublish(): MetricCardProps {
+ const startThisPeriod = DateCalculator.subDays(this.now, this.timeToPublishDays);
+
+ let sumDays = 0;
+ let count = 0;
+ for (const entry of this.entries) {
+ const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt);
+ if (!publishedAt) continue;
+ if (!DateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) continue;
+
+ const createdAt = DateCalculator.parseDate(entry?.sys?.createdAt);
+ 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 ${this.timeToPublishDays} days`
+ : `For the last ${this.timeToPublishDays} days`,
+ icon: ClockIcon,
+ isNegative: false,
+ };
+ }
+
+ private calculateScheduled(): MetricCardProps {
+ const end = DateCalculator.addDays(this.now, 30);
+
+ let count = 0;
+ for (const action of this.scheduledActions) {
+ const scheduledFor = DateCalculator.parseDate(action?.scheduledFor?.datetime);
+ if (!scheduledFor) continue;
+ if (DateCalculator.isWithin(scheduledFor, this.now, end)) {
+ count += 1;
+ }
+ }
+
+ return {
+ title: 'Scheduled',
+ value: String(count),
+ subtitle: 'For the next 30 days',
+ icon: CalendarDotsIcon,
+ isNegative: false,
+ };
+ }
+
+ private calculateRecentlyPublished(): MetricCardProps {
+ const start = DateCalculator.subDays(this.now, this.recentlyPublishedDays);
+
+ let count = 0;
+ for (const entry of this.entries) {
+ const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt);
+ if (!publishedAt) continue;
+ if (DateCalculator.isWithin(publishedAt, start, this.now)) {
+ count += 1;
+ }
+ }
+
+ return {
+ title: 'Recently Published',
+ value: String(count),
+ subtitle: `In the last ${this.recentlyPublishedDays} days`,
+ icon: ClockIcon,
+ isNegative: false,
+ };
+ }
+
+ private calculateNeedsUpdate(): MetricCardProps {
+ const cutoff = DateCalculator.subMonths(this.now, this.needsUpdateMonths);
+
+ let count = 0;
+ for (const entry of this.entries) {
+ const updatedAt = DateCalculator.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 ${this.needsUpdateMonths} months`,
+ icon: PenNibIcon,
+ isNegative: false,
+ };
+ }
+}
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);
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..2c8a193b5f
--- /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 {
+ static parseDate(value: string | undefined): MaybeDate {
+ if (!value) return undefined;
+ const ms = Date.parse(value);
+ return Number.isNaN(ms) ? undefined : new Date(ms);
+ }
+
+ static addDays(date: Date, days: number): Date {
+ const result = new Date(date);
+ result.setDate(result.getDate() + days);
+ return result;
+ }
+
+ static subDays(date: Date, days: number): Date {
+ const result = new Date(date);
+ result.setDate(result.getDate() - days);
+ return result;
+ }
+
+ static subMonths(base: Date, months: number): Date {
+ const date = new Date(base);
+ date.setMonth(date.getMonth() - months);
+ return date;
+ }
+
+ static 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/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(),
+ };
+};
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..ea52dab2e2
--- /dev/null
+++ b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from '@testing-library/react';
+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,
+ 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(),
+ }),
+}));
+
+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(, { wrapper: createWrapper() });
+
+ expect(screen.getByText('Content Dashboard')).toBeInTheDocument();
+ });
+
+ it('renders all metric cards', () => {
+ render(, { wrapper: createWrapper() });
+
+ expect(screen.getByText('Total Published')).toBeInTheDocument();
+ expect(screen.getByText('Average Time to Publish')).toBeInTheDocument();
+ expect(screen.getByText('Scheduled')).toBeInTheDocument();
+ 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
index 6cb3e0eccf..8dea5c25fc 100644
--- a/apps/content-production-dashboard/test/locations/Home.spec.tsx
+++ b/apps/content-production-dashboard/test/locations/Home.spec.tsx
@@ -1,17 +1,72 @@
-import { render } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
-import { mockCma, mockSdk } from '../mocks';
+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,
- useCMA: () => mockCma,
}));
describe('Home component', () => {
- it('Component text exists', () => {
- const { getByText } = render();
+ 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(getByText('Hello Home Component (AppId: test-app)')).toBeTruthy();
+ 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();
- });
});
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..afb6751f3f
--- /dev/null
+++ b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts
@@ -0,0 +1,250 @@
+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', () => {
+ 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.getAllMetrics()).toHaveLength(5);
+ });
+
+ it('uses default values when options are not provided', () => {
+ const calculator = new MetricsCalculator([], []);
+ 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(
+ `${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 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');
+ 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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().find((m) => m.title === 'Scheduled');
+
+ expect(metric?.value).toBe('2');
+ });
+ });
+
+ 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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().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.getAllMetrics().find((m) => m.title === 'Needs Update');
+
+ expect(metric?.value).toBe('1');
+ });
+ });
+});
diff --git a/apps/content-production-dashboard/test/mocks/mockSdk.ts b/apps/content-production-dashboard/test/mocks/mockSdk.ts
index ecc92d2786..115ae2a1ba 100644
--- a/apps/content-production-dashboard/test/mocks/mockSdk.ts
+++ b/apps/content-production-dashboard/test/mocks/mockSdk.ts
@@ -13,6 +13,9 @@ const mockSdk: any = {
space: 'test-space',
environment: 'test-environment',
},
+ parameters: {
+ installation: {},
+ },
notifier: {
error: vi.fn(),
success: vi.fn(),