mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 13:38:49 +08:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+107
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+72
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user