feat: doc status & share status (#14426)

#### PR Dependency Tree


* **PR #14426** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Admin dashboard: view workspace analytics (storage, sync activity, top
shared links) with charts and configurable windows.
* Document analytics tab: see total/unique/guest views and trends over
selectable time windows.
* Last-accessed members: view who last accessed a document, with
pagination.
* Shared links analytics: browse and paginate all shared links with
view/unique/guest metrics and share URLs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-13 01:01:29 +08:00
committed by GitHub
parent b46bf91575
commit b4be9118ad
44 changed files with 5701 additions and 86 deletions
@@ -43,6 +43,7 @@ import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
import {
AiIcon,
ChartPanelIcon,
CommentIcon,
ExportIcon,
FrameIcon,
@@ -67,6 +68,7 @@ import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';
import { DetailPageWrapper } from './detail-page-wrapper';
import { EditorAdapterPanel } from './tabs/adapter';
import { EditorAnalyticsPanel } from './tabs/analytics';
import { EditorChatPanel } from './tabs/chat';
import { EditorFramePanel } from './tabs/frame';
import { EditorJournalPanel } from './tabs/journal';
@@ -433,6 +435,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</ViewSidebarTab>
)}
{workspace.flavour === 'affine-cloud' && (
<ViewSidebarTab tabId="analytics" icon={<ChartPanelIcon />}>
<Scrollable.Root className={styles.sidebarScrollArea}>
<Scrollable.Viewport>
<EditorAnalyticsPanel workspaceId={workspace.id} docId={doc.id} />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</ViewSidebarTab>
)}
<GlobalPageHistoryModal />
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
</FrameworkScope>
@@ -0,0 +1,229 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
minHeight: '100%',
padding: '16px',
gap: '20px',
});
export const section = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
});
export const sectionHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
});
export const sectionTitle = style({
display: 'flex',
alignItems: 'baseline',
gap: '8px',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
fontWeight: 600,
});
export const sectionSubtitle = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const windowButton = style({
minWidth: '124px',
justifyContent: 'space-between',
});
export const lockButton = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'transparent',
padding: 0,
margin: 0,
color: cssVar('textSecondaryColor'),
cursor: 'pointer',
selectors: {
'&:hover': {
color: cssVar('textPrimaryColor'),
},
},
});
export const metrics = style({
display: 'flex',
alignItems: 'stretch',
gap: '8px',
});
export const metricCard = style({
minWidth: '0',
flex: 1,
borderRadius: '10px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
});
export const metricLabel = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.2,
});
export const metricValue = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
fontWeight: 600,
lineHeight: 1.2,
fontVariantNumeric: 'tabular-nums',
});
export const chartContainer = style({
height: '228px',
borderRadius: '12px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
padding: '10px 10px 8px 10px',
});
export const axisLabels = style({
display: 'flex',
justifyContent: 'space-between',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
fontVariantNumeric: 'tabular-nums',
marginTop: '4px',
});
export const chartLegend = style({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
});
export const legendItem = style({
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
});
export const legendDot = style({
width: '8px',
height: '8px',
borderRadius: '50%',
});
export const tooltip = style({
minWidth: '160px',
borderRadius: '8px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
boxShadow: cssVar('shadow2'),
padding: '8px 10px',
});
export const tooltipTitle = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
marginBottom: '6px',
});
export const tooltipRow = style({
display: 'flex',
alignItems: 'center',
gap: '6px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.4,
});
export const tooltipValue = style({
marginLeft: 'auto',
color: cssVar('textPrimaryColor'),
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
});
export const emptyState = style({
borderRadius: '10px',
border: `1px dashed ${cssVar('borderColor')}`,
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
minHeight: '96px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '0 16px',
});
export const viewersList = style({
display: 'flex',
flexDirection: 'column',
gap: '6px',
});
export const viewerRow = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
borderRadius: '8px',
padding: '6px 8px',
selectors: {
'&:hover': {
backgroundColor: cssVar('hoverColor'),
},
},
});
export const viewerUser = style({
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const viewerName = style({
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const viewerTime = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
});
export const loadMoreButton = style({
alignSelf: 'flex-start',
});
export const loading = style({
minHeight: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
@@ -0,0 +1,511 @@
import {
Avatar,
Button,
Loading,
Menu,
MenuItem,
toast,
} from '@affine/component';
import { useQuery } from '@affine/core/components/hooks/use-query';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import {
getDocLastAccessedMembersQuery,
getDocPageAnalyticsQuery,
} from '@affine/graphql';
import { i18nTime, useI18n } from '@affine/i18n';
import {
ArrowDownSmallIcon,
CalendarPanelIcon,
LockIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Area,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
type TooltipProps,
XAxis,
YAxis,
} from 'recharts';
import * as styles from './analytics.css';
import {
type AnalyticsChartPoint,
buildAnalyticsChartPoints,
clampAnalyticsWindowDays,
DEFAULT_ANALYTICS_WINDOW_DAYS,
ensureMinimumChartPoints,
getAvailableAnalyticsWindowOptions,
INITIAL_MEMBERS_PAGE_SIZE,
isLockedAnalyticsWindowOption,
MAX_MEMBERS_PAGE_SIZE,
} from './analytics.utils';
const intFormatter = new Intl.NumberFormat('en-US');
const totalViewsColor = cssVar('primaryColor');
const uniqueViewsColor = cssVar('processingColor');
function formatChartDate(value: string) {
return i18nTime(value, { absolute: { accuracy: 'day' } });
}
function AnalyticsChartTooltip({
active,
payload,
}: TooltipProps<number, string>) {
const t = useI18n();
if (!active || !payload?.length) {
return null;
}
const point = payload[0]?.payload as AnalyticsChartPoint | undefined;
if (!point) {
return null;
}
const valueByKey = payload.reduce<Record<string, number>>((acc, item) => {
if (!item.dataKey) {
return acc;
}
acc[String(item.dataKey)] =
typeof item.value === 'number' ? item.value : Number(item.value ?? 0);
return acc;
}, {});
return (
<div className={styles.tooltip}>
<div className={styles.tooltipTitle}>{formatChartDate(point.date)}</div>
<div className={styles.tooltipRow}>
<span
className={styles.legendDot}
style={{ backgroundColor: totalViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.total-views']()}
<span className={styles.tooltipValue}>
{intFormatter.format(valueByKey.totalViews ?? point.totalViews)}
</span>
</div>
<div className={styles.tooltipRow}>
<span
className={styles.legendDot}
style={{ backgroundColor: uniqueViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.unique-views']()}
<span className={styles.tooltipValue}>
{intFormatter.format(valueByKey.uniqueViews ?? point.uniqueViews)}
</span>
</div>
</div>
);
}
export const EditorAnalyticsPanel = ({
workspaceId,
docId,
}: {
workspaceId: string;
docId: string;
}) => {
const t = useI18n();
const permission = useService(WorkspacePermissionService).permission;
const workspaceDialogService = useService(WorkspaceDialogService);
const isTeam = useLiveData(permission.isTeam$);
const isTeamWorkspace = isTeam ?? false;
const [windowDays, setWindowDays] = useState(DEFAULT_ANALYTICS_WINDOW_DAYS);
const [membersPageSize, setMembersPageSize] = useState(
INITIAL_MEMBERS_PAGE_SIZE
);
const allowedWindowOptions = useMemo(
() => getAvailableAnalyticsWindowOptions(),
[]
);
const effectiveWindowDays = useMemo(
() => clampAnalyticsWindowDays(windowDays, isTeamWorkspace),
[isTeamWorkspace, windowDays]
);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[]
);
useEffect(() => {
setMembersPageSize(INITIAL_MEMBERS_PAGE_SIZE);
}, [docId]);
useEffect(() => {
permission.revalidate();
}, [permission, workspaceId]);
useEffect(() => {
if (windowDays !== effectiveWindowDays) {
setWindowDays(effectiveWindowDays);
}
}, [effectiveWindowDays, windowDays]);
const {
data: analyticsData,
isLoading: analyticsLoading,
error: analyticsError,
} = useQuery(
{
query: getDocPageAnalyticsQuery,
variables: {
workspaceId,
docId,
input: {
windowDays: effectiveWindowDays,
timezone,
},
},
},
{
suspense: false,
keepPreviousData: true,
revalidateOnFocus: false,
shouldRetryOnError: false,
}
);
const {
data: membersData,
isLoading: membersLoading,
error: membersError,
} = useQuery(
{
query: getDocLastAccessedMembersQuery,
variables: {
workspaceId,
docId,
pagination: {
first: membersPageSize,
offset: 0,
},
includeTotal: true,
},
},
{
suspense: false,
keepPreviousData: true,
revalidateOnFocus: false,
shouldRetryOnError: false,
}
);
const analytics = analyticsData?.workspace.doc.analytics;
const summary = analytics?.summary;
const chartPoints = useMemo(
() =>
ensureMinimumChartPoints(
buildAnalyticsChartPoints(analytics?.series ?? [])
),
[analytics?.series]
);
const membersConnection = membersData?.workspace.doc.lastAccessedMembers;
const members = useMemo(
() => membersConnection?.edges.map(edge => edge.node) ?? [],
[membersConnection?.edges]
);
const totalMembers = membersConnection?.totalCount ?? members.length;
const hasMoreMembers =
Boolean(membersConnection?.pageInfo.hasNextPage) &&
membersPageSize < MAX_MEMBERS_PAGE_SIZE;
const openTeamPricing = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
}, [workspaceDialogService]);
const showTeamPlanToast = useCallback(() => {
toast(t['com.affine.doc.analytics.paywall.toast']());
}, [t]);
return (
<div className={styles.root}>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>
<span>{t['com.affine.doc.analytics.title']()}</span>
<span className={styles.sectionSubtitle}>
{summary
? t.t('com.affine.doc.analytics.summary.total', {
count: intFormatter.format(summary.totalViews),
})
: ''}
</span>
</div>
<Menu
contentOptions={{ align: 'end' }}
items={
<>
{allowedWindowOptions.map(option => {
const isLocked = isLockedAnalyticsWindowOption(
option,
isTeamWorkspace
);
return (
<MenuItem
key={option}
selected={effectiveWindowDays === option}
suffixIcon={
isLocked ? (
<button
type="button"
className={styles.lockButton}
aria-label={t[
'com.affine.doc.analytics.paywall.open-pricing'
]()}
onClick={event => {
event.preventDefault();
event.stopPropagation();
openTeamPricing();
}}
>
<LockIcon />
</button>
) : undefined
}
onSelect={() => {
if (isLocked) {
showTeamPlanToast();
return;
}
setWindowDays(option);
}}
>
{t.t('com.affine.doc.analytics.window.last-days', {
days: option,
})}
{isLocked
? ` (${t['com.affine.payment.cloud.team-workspace.name']()})`
: ''}
</MenuItem>
);
})}
</>
}
>
<Button
variant="secondary"
size="default"
className={styles.windowButton}
prefix={<CalendarPanelIcon />}
suffix={<ArrowDownSmallIcon />}
>
{t.t('com.affine.doc.analytics.window.last-days', {
days: effectiveWindowDays,
})}
</Button>
</Menu>
</div>
<div className={styles.metrics}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.total']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.totalViews ?? 0)}
</div>
</div>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.unique']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.uniqueViews ?? 0)}
</div>
</div>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.guest']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.guestViews ?? 0)}
</div>
</div>
</div>
{analyticsLoading && !analytics ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : analyticsError && !analytics ? (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.error.load-analytics']()}
</div>
) : chartPoints.length ? (
<>
<div className={styles.chartContainer}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartPoints}
margin={{ top: 10, right: 6, bottom: 6, left: 6 }}
>
<CartesianGrid
vertical={false}
stroke={cssVar('borderColor')}
strokeDasharray="3 3"
/>
<XAxis
dataKey="x"
type="number"
hide
allowDecimals={false}
domain={['dataMin', 'dataMax']}
/>
<YAxis
hide
domain={[
0,
(max: number) => {
if (max <= 0) {
return 1;
}
return Math.ceil(max * 1.1);
},
]}
/>
<RechartsTooltip
cursor={{
stroke: cssVar('borderColor'),
strokeDasharray: '4 4',
}}
content={<AnalyticsChartTooltip />}
/>
<Area
dataKey="totalViews"
type="monotone"
stroke={totalViewsColor}
fill={totalViewsColor}
fillOpacity={0.15}
isAnimationActive={false}
/>
<Area
dataKey="uniqueViews"
type="monotone"
stroke={uniqueViewsColor}
fill={uniqueViewsColor}
fillOpacity={0.1}
isAnimationActive={false}
/>
<Line
dataKey="totalViews"
type="monotone"
stroke={totalViewsColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
<Line
dataKey="uniqueViews"
type="monotone"
stroke={uniqueViewsColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className={styles.axisLabels}>
<span>{formatChartDate(chartPoints[0].date)}</span>
<span>
{formatChartDate(chartPoints[chartPoints.length - 1].date)}
</span>
</div>
<div className={styles.chartLegend}>
<span className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: totalViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.total-views']()}
</span>
<span className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: uniqueViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.unique-views']()}
</span>
</div>
</>
) : (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.empty.no-page-views']()}
</div>
)}
</section>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>
<span>{t['com.affine.doc.analytics.viewers.title']()}</span>
<span className={styles.sectionSubtitle}>
({intFormatter.format(totalMembers)})
</span>
</div>
</div>
{membersLoading && !membersConnection ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : membersError && !membersConnection ? (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.error.load-viewers']()}
</div>
) : members.length ? (
<>
<div className={styles.viewersList}>
{members.map(member => (
<div className={styles.viewerRow} key={member.user.id}>
<div className={styles.viewerUser}>
<Avatar
size={24}
url={member.user.avatarUrl || ''}
name={member.user.name}
/>
<span className={styles.viewerName}>
{member.user.name}
</span>
</div>
<span className={styles.viewerTime}>
{i18nTime(member.lastAccessedAt, { relative: true })}
</span>
</div>
))}
</div>
{hasMoreMembers ? (
<Button
variant="plain"
className={styles.loadMoreButton}
onClick={() => setMembersPageSize(MAX_MEMBERS_PAGE_SIZE)}
>
{t['com.affine.doc.analytics.viewers.show-all']()}
</Button>
) : null}
</>
) : (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.empty.no-viewers']()}
</div>
)}
</section>
</div>
);
};
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'vitest';
import {
ANALYTICS_WINDOW_OPTIONS,
buildAnalyticsChartPoints,
clampAnalyticsWindowDays,
DEFAULT_ANALYTICS_WINDOW_DAYS,
ensureMinimumChartPoints,
getAvailableAnalyticsWindowOptions,
isLockedAnalyticsWindowOption,
NON_TEAM_ANALYTICS_WINDOW_DAYS,
} from './analytics.utils';
describe('analytics.utils', () => {
test('clampAnalyticsWindowDays returns default for unsupported values', () => {
expect(clampAnalyticsWindowDays(28, true)).toBe(28);
expect(clampAnalyticsWindowDays(15, true)).toBe(
DEFAULT_ANALYTICS_WINDOW_DAYS
);
});
test('clampAnalyticsWindowDays returns only 7 days for non-team workspaces', () => {
expect(clampAnalyticsWindowDays(7, false)).toBe(
NON_TEAM_ANALYTICS_WINDOW_DAYS
);
expect(clampAnalyticsWindowDays(28, false)).toBe(
NON_TEAM_ANALYTICS_WINDOW_DAYS
);
});
test('getAvailableAnalyticsWindowOptions keeps all options visible', () => {
expect(getAvailableAnalyticsWindowOptions()).toEqual([
...ANALYTICS_WINDOW_OPTIONS,
]);
});
test('isLockedAnalyticsWindowOption locks windows over 7 days for non-team workspaces', () => {
expect(
isLockedAnalyticsWindowOption(NON_TEAM_ANALYTICS_WINDOW_DAYS, false)
).toBe(false);
expect(isLockedAnalyticsWindowOption(14, false)).toBe(true);
expect(isLockedAnalyticsWindowOption(14, true)).toBe(false);
});
test('buildAnalyticsChartPoints sorts series by date and maps values', () => {
const points = buildAnalyticsChartPoints([
{
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
{
date: '2026-02-10',
totalViews: 9,
uniqueViews: 3,
guestViews: 0,
},
]);
expect(points).toEqual([
{
x: 0,
date: '2026-02-10',
totalViews: 9,
uniqueViews: 3,
guestViews: 0,
},
{
x: 1,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
});
test('ensureMinimumChartPoints duplicates the only data point', () => {
const points = ensureMinimumChartPoints([
{
x: 0,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
expect(points).toEqual([
{
x: 0,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
{
x: 1,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
});
});
@@ -0,0 +1,72 @@
import type { GetDocPageAnalyticsQuery } from '@affine/graphql';
export const ANALYTICS_WINDOW_OPTIONS = [7, 14, 28, 60, 90] as const;
export const DEFAULT_ANALYTICS_WINDOW_DAYS = 28;
export const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7;
export const INITIAL_MEMBERS_PAGE_SIZE = 5;
export const MAX_MEMBERS_PAGE_SIZE = 50;
export type AnalyticsSeriesPoint =
GetDocPageAnalyticsQuery['workspace']['doc']['analytics']['series'][number];
export type AnalyticsChartPoint = {
x: number;
date: string;
totalViews: number;
uniqueViews: number;
guestViews: number;
};
export function getAvailableAnalyticsWindowOptions() {
return [...ANALYTICS_WINDOW_OPTIONS];
}
export function isLockedAnalyticsWindowOption(
value: number,
isTeamWorkspace: boolean
) {
return !isTeamWorkspace && value > NON_TEAM_ANALYTICS_WINDOW_DAYS;
}
export function clampAnalyticsWindowDays(
value: number,
isTeamWorkspace: boolean
) {
if (!isTeamWorkspace) {
return NON_TEAM_ANALYTICS_WINDOW_DAYS;
}
return ANALYTICS_WINDOW_OPTIONS.includes(
value as (typeof ANALYTICS_WINDOW_OPTIONS)[number]
)
? value
: DEFAULT_ANALYTICS_WINDOW_DAYS;
}
export function buildAnalyticsChartPoints(series: AnalyticsSeriesPoint[]) {
const sorted = [...series].sort(
(left, right) =>
new Date(left.date).getTime() - new Date(right.date).getTime()
);
return sorted.map((point, index) => ({
x: index,
date: point.date,
totalViews: point.totalViews,
uniqueViews: point.uniqueViews,
guestViews: point.guestViews,
})) satisfies AnalyticsChartPoint[];
}
export function ensureMinimumChartPoints(points: AnalyticsChartPoint[]) {
if (points.length !== 1) {
return points;
}
return [
points[0],
{
...points[0],
x: points[0].x + 1,
},
] satisfies AnalyticsChartPoint[];
}