Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/content-production-dashboard/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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';

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 metrics = new MetricsCalculator(entries, scheduledActions, {
needsUpdateMonths: installation.needsUpdateMonths,
recentlyPublishedDays: installation.recentlyPublishedDays,
timeToPublishDays: installation.timeToPublishDays,
}).metrics;

return (
<Flex flexDirection="column" style={styles.container}>
<Box marginBottom="spacingXs">
<Heading>Content Dashboard</Heading>
</Box>

{error ? (
<ErrorDisplay error={error} />
) : isFetching ? (
<LoadingSkeleton />
) : (
<>
<Flex flexDirection="row" gap="spacingM">
{metrics.map((metric) => {
return (
<MetricCard
key={metric.title}
title={metric.title}
value={metric.value}
subtitle={metric.subtitle}
icon={metric.icon}
isNegative={metric.isNegative}
/>
);
})}
</Flex>
</>
)}
</Flex>
);
};

export default Dashboard;
Original file line number Diff line number Diff line change
@@ -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,
};
34 changes: 34 additions & 0 deletions apps/content-production-dashboard/src/components/MetricCard.tsx
Original file line number Diff line number Diff line change
@@ -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<IconProps>;
isNegative?: boolean;
};

export const MetricCard = ({ title, value, subtitle, icon: Icon, isNegative }: MetricCardProps) => {
return (
<Card padding="default" style={styles.card}>
<Flex justifyContent="space-between" alignItems="center">
<Flex flexDirection="column" gap="spacing2Xs">
<Text fontSize="fontSizeS" fontColor="gray600" fontWeight="fontWeightDemiBold">
{title}
</Text>
<Text fontSize="fontSizeXl" fontWeight="fontWeightDemiBold">
{value}
</Text>
<Text fontSize="fontSizeS" fontColor={isNegative ? 'red600' : 'gray500'}>
{subtitle}
</Text>
</Flex>
<Icon color={tokens.gray900} />
</Flex>
</Card>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<PageAppSDK>();

const { data, isFetching, error, refetch } = useQuery<FetchAllEntriesResult, Error>({
const { data, isFetching, error } = useQuery<FetchAllEntriesResult, Error>({
queryKey: ['entries', sdk.ids.space, sdk.ids.environment],
queryFn: () => fetchAllEntries(sdk),
});
Expand All @@ -26,7 +25,6 @@ export function useAllEntries(): UseAllEntriesResult {
total: data?.total || 0,
isFetching,
error,
refetch,
fetchedAt: data?.fetchedAt,
};
}
8 changes: 2 additions & 6 deletions apps/content-production-dashboard/src/locations/Home.tsx
Original file line number Diff line number Diff line change
@@ -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<HomeAppSDK>();

return <Paragraph>Hello Home Component (AppId: {sdk.ids.app})</Paragraph>;
return <Dashboard />;
};

export default Home;
Original file line number Diff line number Diff line change
@@ -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,
};
27 changes: 2 additions & 25 deletions apps/content-production-dashboard/src/locations/Page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box padding="spacingXl" className={styles.pageContainer}>
<Flex justifyContent="space-between" alignItems="center" marginBottom="spacingL">
<Heading>Content Dashboard</Heading>
<Button onClick={() => refetch()} variant="secondary" size="small" isDisabled={isFetching}>
{isFetching ? 'Refreshing...' : 'Refresh'}
</Button>
</Flex>

{error ? (
<ErrorDisplay error={error} />
) : isFetching ? (
<LoadingSkeleton />
) : (
<>{/* TODO: implement the rest of the sections */}</>
)}
</Box>
);
return <Dashboard />;
};

export default Page;
179 changes: 179 additions & 0 deletions apps/content-production-dashboard/src/metrics/MetricsCalculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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 { msPerDay, parseDate, addDays, subDays, subMonths, isWithin } from '../utils/dates';
import { percentChange } from '../utils/metrics';

export class MetricsCalculator {
private readonly entries: ReadonlyArray<EntryProps>;
private readonly scheduledActions: ReadonlyArray<ScheduledActionProps>;
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<MetricCardProps>;

constructor(
entries: ReadonlyArray<EntryProps>,
scheduledActions: ReadonlyArray<ScheduledActionProps>,
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;

this.metrics = [
this.calculateTotalPublished(),
this.calculateAverageTimeToPublish(),
this.calculateScheduled(),
this.calculateRecentlyPublished(),
this.calculateNeedsUpdate(),
];
}

private calculateTotalPublished(): MetricCardProps {
const startThisPeriod = subDays(this.now, 30);
const startPrevPeriod = 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);
if (!publishedAt) continue;

if (isWithin(publishedAt, startThisPeriod, this.now)) {
current += 1;
continue;
}
if (isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) {
previous += 1;
}
}

const { text, isNegative } = percentChange(current, previous);

return {
title: 'Total Published',
value: String(current),
subtitle: text,
icon: FileIcon,
isNegative,
};
}

private calculateAverageTimeToPublish(): MetricCardProps {
const startThisPeriod = subDays(this.now, this.timeToPublishDays);

let sumDays = 0;
let count = 0;
for (const entry of this.entries) {
const publishedAt = parseDate(entry?.sys?.publishedAt);
if (!publishedAt) continue;
if (!isWithin(publishedAt, startThisPeriod, this.now)) continue;

const createdAt = 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 = 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,
};
}

private calculateRecentlyPublished(): MetricCardProps {
const start = subDays(this.now, this.recentlyPublishedDays);

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 ${this.recentlyPublishedDays} days`,
icon: ClockIcon,
isNegative: false,
};
}

private calculateNeedsUpdate(): MetricCardProps {
const cutoff = subMonths(this.now, this.needsUpdateMonths);

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 ${this.needsUpdateMonths} months`,
icon: PenNibIcon,
isNegative: false,
};
}
}
27 changes: 27 additions & 0 deletions apps/content-production-dashboard/src/utils/dates.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading