diff --git a/apps/admin-x-framework/src/api/stats.ts b/apps/admin-x-framework/src/api/stats.ts index 5946659bb47..98f515f15a9 100644 --- a/apps/admin-x-framework/src/api/stats.ts +++ b/apps/admin-x-framework/src/api/stats.ts @@ -242,6 +242,55 @@ export const useSubscriptionStats = createQuery({ path: '/stats/subscriptions/' }); +// Comments analytics overview + +export type CommentsOverviewTotals = { + comments: number; + commenters: number; + reported: number; +}; + +export type CommentsOverviewSeriesItem = { + date: string; + count: number; + commenters: number; + reported: number; +}; + +export type CommentsOverviewTopPost = { + id: string; + title: string; + slug: string; + count: number; +}; + +export type CommentsOverviewTopMember = { + id: string; + name: string | null; + email: string; + count: number; +}; + +export type CommentsOverview = { + totals: CommentsOverviewTotals; + previousTotals: CommentsOverviewTotals | null; + series: CommentsOverviewSeriesItem[]; + topPosts: CommentsOverviewTopPost[]; + topMembers: CommentsOverviewTopMember[]; +}; + +export type CommentsOverviewResponseType = { + stats: CommentsOverview[]; + meta?: Meta; +}; + +const commentsOverviewDataType = 'CommentsOverviewResponseType'; + +export const useCommentsOverview = createQuery({ + dataType: commentsOverviewDataType, + path: '/stats/comments/' +}); + export const usePostStats = createQueryWithId({ dataType: 'PostStatsResponseType', path: id => `/stats/posts/${id}/stats/` diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index 3207499718a..d27078c25ff 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -67,6 +67,10 @@ const features: Feature[] = [{ title: 'Gift Subscriptions', description: 'Allow site visitors to purchase gift subscriptions for others', flag: 'giftSubscriptions' +}, { + title: 'Comment Analytics', + description: 'Show an analytics overview (KPIs, chart, top posts & commenters) above the comments moderation list', + flag: 'commentAnalytics' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/posts/src/views/comments/comments.tsx b/apps/posts/src/views/comments/comments.tsx index 8b667eb2341..046f94d55db 100644 --- a/apps/posts/src/views/comments/comments.tsx +++ b/apps/posts/src/views/comments/comments.tsx @@ -1,3 +1,4 @@ +import CommentsAnalytics from './components/comments-analytics'; import CommentsContent from './components/comments-content'; import CommentsFilters from './components/comments-filters'; import CommentsHeader from './components/comments-header'; @@ -8,10 +9,17 @@ import {Button, EmptyIndicator, LoadingIndicator} from '@tryghost/shade/componen import {LucideIcon} from '@tryghost/shade/utils'; import {createFilter} from '@tryghost/shade/patterns'; import {useBrowseComments} from '@tryghost/admin-x-framework/api/comments'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useFilterState} from './hooks/use-filter-state'; +import {useOverviewRange} from './hooks/use-overview-range'; const Comments: React.FC = () => { const {filters, nql, setFilters, clearFilters, isSingleIdFilter} = useFilterState(); + const {data: configData} = useBrowseConfig(); + const analyticsEnabled = configData?.config?.labs?.commentAnalytics === true; + + const {range, setRange, dateFrom, dateTo, timezone} = useOverviewRange(); + const handleAddFilter = useCallback((field: string, value: string, operator: string = 'is') => { setFilters((prevFilters) => { // Remove any existing filter for the same field @@ -36,8 +44,19 @@ const Comments: React.FC = () => { // If we are fetching comments, but not fetching the next page and not refetching, we should show the loading indicator const shouldShowLoading = isFetching && !isFetchingNextPage && !isRefetching; + const rail = analyticsEnabled ? ( + + ) : undefined; + return ( - + {!isSingleIdFilter && ( void; + dateFrom: string; + dateTo: string; + timezone: string; + /** + * Applies a filter to the moderation list rendered alongside this rail. + * Used by top-posts/commenters row clicks and chart bar clicks. + */ + onAddFilter: (field: string, value: string, operator?: string) => void; +} + +const EMPTY_OVERVIEW: CommentsOverviewPayload = { + totals: {comments: 0, commenters: 0, reported: 0}, + previousTotals: null, + series: [], + topPosts: [], + topMembers: [] +}; + +const CommentsAnalytics: React.FC = ({range, setRange, dateFrom, dateTo, timezone, onAddFilter}) => { + const searchParams = useMemo(() => ({ + date_from: dateFrom, + date_to: dateTo, + timezone + }), [dateFrom, dateTo, timezone]); + + const {data, isLoading} = useCommentsOverview({searchParams}) as { + data: CommentsOverviewResponseType | undefined; + isLoading: boolean; + }; + + const overview = data?.stats?.[0] ?? EMPTY_OVERVIEW; + + return ( +
+
+

Analytics

+ +
+ + onAddFilter('post', postId)} + /> + onAddFilter('author', memberId)} + /> +
+ ); +}; + +export default CommentsAnalytics; diff --git a/apps/posts/src/views/comments/components/comments-header.tsx b/apps/posts/src/views/comments/components/comments-header.tsx index e22ae757051..632a7ce6d7c 100644 --- a/apps/posts/src/views/comments/components/comments-header.tsx +++ b/apps/posts/src/views/comments/components/comments-header.tsx @@ -1,9 +1,14 @@ import React from 'react'; import {Header} from '@tryghost/shade/primitives'; +// The shade Header primitive is `@deprecated` and ships with `sticky top-0 z-50` +// plus `-mb-4 lg:-mb-8` baked in (to create the backdrop-blur overlap effect). +// We want the grid areas it provides — `[grid-area:title]`, `[grid-area:actions]`, +// `[grid-area:nav]` — because `CommentsFilters` positions itself against them. +// The overrides here neutralise the sticky+negative-margin without losing the grid. const CommentsHeader: React.FC = ({children}) => { return ( -
+
Comments {children}
diff --git a/apps/posts/src/views/comments/components/comments-layout.tsx b/apps/posts/src/views/comments/components/comments-layout.tsx index 3493f68da2b..63b76b2e003 100644 --- a/apps/posts/src/views/comments/components/comments-layout.tsx +++ b/apps/posts/src/views/comments/components/comments-layout.tsx @@ -1,12 +1,34 @@ import MainLayout from '@components/layout/main-layout'; import React from 'react'; -const CommentsLayout: React.FC<{children: React.ReactNode}> = ({children}) => { +interface CommentsLayoutProps { + children: React.ReactNode; + /** + * Optional right-side column rendered alongside the main content on `lg+`. + * On smaller viewports it collapses above the main column so the analytics + * overview still appears at the top of the page. + */ + rail?: React.ReactNode; +} + +const CommentsLayout: React.FC = ({children, rail}) => { + const body = rail ? ( +
+ {/* `self-start` is required for sticky — grid cells otherwise stretch to the row's height. */} + +
+ {children} +
+
+ ) : children; + return ( -
-
- {children} +
+
+ {body}
diff --git a/apps/posts/src/views/comments/components/overview-date-range.tsx b/apps/posts/src/views/comments/components/overview-date-range.tsx new file mode 100644 index 00000000000..b1778a919a3 --- /dev/null +++ b/apps/posts/src/views/comments/components/overview-date-range.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {LucideIcon} from '@tryghost/shade/utils'; +import {STATS_RANGES} from '@src/utils/constants'; +import {Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue} from '@tryghost/shade/components'; + +interface OverviewDateRangeProps { + range: number; + onRangeChange: (value: number) => void; +} + +const OPTIONS = Object.values(STATS_RANGES); + +const OverviewDateRange: React.FC = ({range, onRangeChange}) => { + return ( + + ); +}; + +export default OverviewDateRange; diff --git a/apps/posts/src/views/comments/components/overview-kpi-tabs.tsx b/apps/posts/src/views/comments/components/overview-kpi-tabs.tsx new file mode 100644 index 00000000000..50027b5f190 --- /dev/null +++ b/apps/posts/src/views/comments/components/overview-kpi-tabs.tsx @@ -0,0 +1,258 @@ +import React, {useMemo, useState} from 'react'; +import {BarChartLoadingIndicator, Card, CardContent, ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, KpiTabTrigger, KpiTabValue, Tabs, TabsList} from '@tryghost/shade/components'; +import {CommentsOverviewSeriesItem, CommentsOverviewTotals} from '@tryghost/admin-x-framework/api/stats'; +import {LucideIcon, Recharts, formatNumber, formatPercentage} from '@tryghost/shade/utils'; +import {STATS_RANGES} from '@src/utils/constants'; +import {formatDisplayDateWithRange, sanitizeChartData} from '@tryghost/shade/app'; +import {getPreviousPeriodText} from '../utils/period-text'; + +type MetricKey = 'comments' | 'commenters' | 'reported'; + +type DiffDirection = 'up' | 'down' | 'same'; + +interface MetricDiff { + direction: DiffDirection; + diffValue: string; + previousValue: number; +} + +interface OverviewKpiTabsProps { + totals: CommentsOverviewTotals | undefined; + previousTotals: CommentsOverviewTotals | null | undefined; + series: CommentsOverviewSeriesItem[] | undefined; + range: number; + isLoading: boolean; + /** + * Clicking a bar filters the moderation list to comments from that date. + * On the Reported tab, also applies `reported=true`. + */ + onAddFilter: (field: string, value: string, operator?: string) => void; +} + +const TAB_CONFIG: Record = { + comments: {label: 'Comments', color: 'var(--chart-darkblue)', seriesField: 'count', totalsField: 'comments'}, + commenters: {label: 'Commenters', color: 'var(--chart-blue)', seriesField: 'commenters', totalsField: 'commenters'}, + reported: {label: 'Reported', color: 'var(--chart-rose)', seriesField: 'reported', totalsField: 'reported'} +}; + +const METRIC_KEYS: readonly MetricKey[] = ['comments', 'commenters', 'reported']; + +const calcDiff = (current: number, previous: number): MetricDiff => { + if (previous === 0) { + // No prior baseline — any positive value is a "new" 100% increase, zero + // stays flat. Showing "∞%" would be noisy, so we cap at 100%. + const direction: DiffDirection = current > 0 ? 'up' : 'same'; + return { + direction, + diffValue: current > 0 ? formatPercentage(1) : formatPercentage(0), + previousValue: previous + }; + } + const change = (current - previous) / previous; + const direction: DiffDirection = change > 0 ? 'up' : change < 0 ? 'down' : 'same'; + return { + direction, + diffValue: formatPercentage(change), + previousValue: previous + }; +}; + +const buildDiffTooltip = ( + diff: MetricDiff, + range: number +): React.ReactNode => { + const previousPeriodText = getPreviousPeriodText(range); + if (!previousPeriodText) { + return null; + } + const formattedPrevious = formatNumber(diff.previousValue); + + if (diff.direction === 'same') { + return ( + + Unchanged from the {previousPeriodText} + + ); + } + const directionText = diff.direction === 'up' ? 'up' : 'down'; + return ( + + You're trending {directionText} {diff.diffValue} from {formattedPrevious} compared to the {previousPeriodText} + + ); +}; + +const OverviewKpiTabs: React.FC = ({totals, previousTotals, series, range, isLoading, onAddFilter}) => { + const [currentTab, setCurrentTab] = useState('comments'); + + const config = TAB_CONFIG[currentTab]; + + // `sanitizeChartData` aggregates to weekly at range ≥ 91 and monthly for + // YTD / 12-month ranges. A bar in those buckets represents a range, not a + // single day, so clicking it would filter to only the bucket's start date + // — misleading. We only enable bar clicks on daily-bucket ranges. + const isDailyAggregation = range < 91; + + const handleBarClick = (payload: {date?: string} | undefined) => { + if (!isDailyAggregation || !payload?.date) { + return; + } + onAddFilter('created_at', payload.date, 'is'); + if (currentTab === 'reported') { + onAddFilter('reported', 'true', 'is'); + } + }; + + const chartData = useMemo(() => { + if (!series || series.length === 0) { + return [] as {date: string; value: number; formattedValue: string}[]; + } + const aggregated = sanitizeChartData(series, range, config.seriesField, 'sum'); + return aggregated.map((point) => { + const rawValue = (point[config.seriesField] as number) || 0; + return { + date: point.date, + value: rawValue, + formattedValue: formatNumber(rawValue) + }; + }); + }, [series, range, config.seriesField]); + + const chartConfig: ChartConfig = { + value: {label: config.label, color: config.color} + }; + + // No prior period to compare against on "All time" — hide the diff. + const diffsHidden = range === STATS_RANGES.ALL_TIME.value; + const diffs = useMemo(() => { + if (diffsHidden || !totals || !previousTotals) { + return null; + } + const buildEntry = (key: MetricKey) => { + const field = TAB_CONFIG[key].totalsField; + const diff = calcDiff(totals[field], previousTotals[field]); + return {...diff, tooltip: buildDiffTooltip(diff, range)}; + }; + return { + comments: buildEntry('comments'), + commenters: buildEntry('commenters'), + reported: buildEntry('reported') + }; + }, [diffsHidden, totals, previousTotals, range]); + + return ( + // The `:not([data-testid$="-diff"])` carve-out keeps the !text-2xl + // override off the diff badge (which also matches `kpi-value-*`). + + setCurrentTab(value as MetricKey)}> + + {METRIC_KEYS.map((key) => { + const cfg = TAB_CONFIG[key]; + const diff = diffs?.[key]; + return ( + + + + ); + })} + + + {isLoading ? ( +
+ +
+ ) : chartData.length === 0 ? ( +
+ + No {config.label.toLowerCase()} in this period +
+ ) : ( + + + + + + + + + + formatDisplayDateWithRange(date, range)} + tickLine={false} + tickMargin={10} + /> + formatNumber(value)} + tickLine={false} + width={28} + /> + { + const rawDate = payload?.payload?.date as string | undefined; + const tooltipDate = rawDate ? formatDisplayDateWithRange(rawDate, range) : ''; + return ( +
+ {tooltipDate && ( +
{tooltipDate}
+ )} +
+
+ + {config.label} +
+ + {formatNumber(Number(value))} + +
+
+ ); + }} + hideLabel + /> + } + cursor={{fill: 'var(--muted)', opacity: 0.5}} + /> + handleBarClick(data as {date?: string}) : undefined} + /> +
+
+ )} +
+
+
+ ); +}; + +export default OverviewKpiTabs; diff --git a/apps/posts/src/views/comments/components/overview-top-members.tsx b/apps/posts/src/views/comments/components/overview-top-members.tsx new file mode 100644 index 00000000000..a436fe38105 --- /dev/null +++ b/apps/posts/src/views/comments/components/overview-top-members.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import {Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, EmptyIndicator, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from '@tryghost/shade/components'; +import {CommentsOverviewTopMember} from '@tryghost/admin-x-framework/api/stats'; +import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; +import {getPeriodText} from '../utils/period-text'; + +interface OverviewTopMembersProps { + members: CommentsOverviewTopMember[] | undefined; + range: number; + isLoading: boolean; + onRowClick: (memberId: string) => void; +} + +const PREVIEW_LIMIT = 5; + +const renderRows = (members: CommentsOverviewTopMember[], onRowClick: (memberId: string) => void) => ( +
    + {members.map(member => ( +
  • + +
  • + ))} +
+); + +const OverviewTopMembers: React.FC = ({members, range, isLoading, onRowClick}) => { + const hasData = members && members.length > 0; + const previewMembers = members ? members.slice(0, PREVIEW_LIMIT) : []; + const canViewAll = members ? members.length > PREVIEW_LIMIT : false; + + return ( + + + Top commenters + Members who commented most {getPeriodText(range)} + + + {isLoading ? ( +
Loading…
+ ) : !hasData ? ( + + + + ) : ( + renderRows(previewMembers, onRowClick) + )} +
+ {canViewAll && ( + + + + + + + + Top commenters + Members who commented most {getPeriodText(range)} + +
+ {renderRows(members!, onRowClick)} +
+
+
+
+ )} +
+ ); +}; + +export default OverviewTopMembers; diff --git a/apps/posts/src/views/comments/components/overview-top-posts.tsx b/apps/posts/src/views/comments/components/overview-top-posts.tsx new file mode 100644 index 00000000000..12d77edfb6d --- /dev/null +++ b/apps/posts/src/views/comments/components/overview-top-posts.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import {Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, EmptyIndicator, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from '@tryghost/shade/components'; +import {CommentsOverviewTopPost} from '@tryghost/admin-x-framework/api/stats'; +import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; +import {getPeriodText} from '../utils/period-text'; + +interface OverviewTopPostsProps { + posts: CommentsOverviewTopPost[] | undefined; + range: number; + isLoading: boolean; + onRowClick: (postId: string) => void; +} + +const PREVIEW_LIMIT = 5; + +const renderRows = (posts: CommentsOverviewTopPost[], onRowClick: (postId: string) => void) => ( +
    + {posts.map(post => ( +
  • + +
  • + ))} +
+); + +const OverviewTopPosts: React.FC = ({posts, range, isLoading, onRowClick}) => { + const hasData = posts && posts.length > 0; + const previewPosts = posts ? posts.slice(0, PREVIEW_LIMIT) : []; + const canViewAll = posts ? posts.length > PREVIEW_LIMIT : false; + + return ( + + + Top posts + Posts with the most comments {getPeriodText(range)} + + + {isLoading ? ( +
Loading…
+ ) : !hasData ? ( + + + + ) : ( + renderRows(previewPosts, onRowClick) + )} +
+ {canViewAll && ( + + + + + + + + Top posts + Posts with the most comments {getPeriodText(range)} + +
+ {renderRows(posts!, onRowClick)} +
+
+
+
+ )} +
+ ); +}; + +export default OverviewTopPosts; diff --git a/apps/posts/src/views/comments/hooks/use-overview-range.ts b/apps/posts/src/views/comments/hooks/use-overview-range.ts new file mode 100644 index 00000000000..8a11ba64e53 --- /dev/null +++ b/apps/posts/src/views/comments/hooks/use-overview-range.ts @@ -0,0 +1,20 @@ +import {STATS_RANGES} from '@src/utils/constants'; +import {formatQueryDate, getRangeDates} from '@tryghost/shade/app'; +import {useMemo, useState} from 'react'; + +const DEFAULT_RANGE = STATS_RANGES.LAST_30_DAYS.value; + +export const useOverviewRange = () => { + const [range, setRange] = useState(DEFAULT_RANGE); + + const {dateFrom, dateTo, timezone} = useMemo(() => { + const {startDate, endDate, timezone: tz} = getRangeDates(range); + return { + dateFrom: formatQueryDate(startDate), + dateTo: formatQueryDate(endDate), + timezone: tz + }; + }, [range]); + + return {range, setRange, dateFrom, dateTo, timezone}; +}; diff --git a/apps/posts/src/views/comments/utils/period-text.ts b/apps/posts/src/views/comments/utils/period-text.ts new file mode 100644 index 00000000000..168b9f9ab8c --- /dev/null +++ b/apps/posts/src/views/comments/utils/period-text.ts @@ -0,0 +1,45 @@ +import {STATS_RANGES} from '@src/utils/constants'; + +const RANGE_BY_VALUE = new Map( + Object.values(STATS_RANGES).map(option => [option.value, option.name]) +); + +/** + * Returns a phrase describing the selected date range, ready to append to a + * card title or description: e.g. "in the last 30 days", "(all time)". + */ +export const getPeriodText = (range: number): string => { + const name = RANGE_BY_VALUE.get(range); + if (!name) { + return ''; + } + if (['Last 7 days', 'Last 30 days', 'Last 90 days', 'Last 12 months'].includes(name)) { + return `in the ${name.toLowerCase()}`; + } + if (name === 'All time') { + return '(all time)'; + } + return name.toLowerCase(); +}; + +/** + * Returns a phrase describing the prior comparison period, or '' when no + * comparable prior period exists (e.g. "All time"). + */ +export const getPreviousPeriodText = (range: number): string => { + const name = RANGE_BY_VALUE.get(range); + if (!name) { + return ''; + } + if (name === 'Today') { + return 'previous day'; + } + if (name === 'Year to date') { + return 'same period last year'; + } + if (name === 'All time') { + return ''; + } + // "Last 7 days" → "previous 7 days", "Last 12 months" → "previous 12 months" + return name.toLowerCase().replace(/^last /, 'previous '); +}; diff --git a/apps/shade/src/components/ui/tabs.tsx b/apps/shade/src/components/ui/tabs.tsx index f7aec3ebaf1..29c0b2dce04 100644 --- a/apps/shade/src/components/ui/tabs.tsx +++ b/apps/shade/src/components/ui/tabs.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import * as TabsPrimitive from '@radix-ui/react-tabs'; import {DropdownMenuTrigger} from './dropdown-menu'; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from './tooltip'; import {cn} from '@/lib/utils'; import {cva} from 'class-variance-authority'; import {TrendingDown, TrendingUp, type LucideIcon} from 'lucide-react'; @@ -186,6 +187,9 @@ interface KpiTabValueProps { value: string | number; diffDirection?: 'up' | 'down' | 'same' | 'hidden'; diffValue?: string | number; + diffTooltip?: React.ReactNode; + /** Render the diff badge as an icon-only circle (hides the percent value). */ + diffIconOnly?: boolean; className?: string; 'data-testid'?: string; } @@ -197,17 +201,36 @@ const KpiTabValue: React.FC = ({ value, diffDirection, diffValue, + diffTooltip, + diffIconOnly, className, 'data-testid': testId }) => { const IconComponent = iconName ? LucideIcons[iconName] as LucideIcon : null; const diffContainerClassName = cn( - 'flex items-center gap-1 text-xs h-[22px] px-1.5 rounded-xs group/diff cursor-default mt-0.5', + 'flex items-center group/diff cursor-default mt-0.5', + diffIconOnly + ? 'size-5 justify-center rounded-full' + : 'gap-1 text-xs h-[22px] px-1.5 rounded-xs', diffDirection === 'up' && 'text-state-success bg-state-success/10', + diffDirection === 'up' && diffTooltip && 'hover:bg-state-success/20', diffDirection === 'down' && 'text-state-danger bg-state-danger/10', + diffDirection === 'down' && diffTooltip && 'hover:bg-state-danger/20', diffDirection === 'same' && 'text-text-secondary bg-muted' ); + const badge = diffDirection && diffDirection !== 'hidden' ? ( +
+ {!diffIconOnly && {diffValue}} + {diffDirection === 'up' && + + } + {diffDirection === 'down' && + + } +
+ ) : null; + return (
@@ -219,19 +242,25 @@ const KpiTabValue: React.FC = ({
{value}
- {diffDirection && diffDirection !== 'hidden' && - <> -
- {diffValue} - {diffDirection === 'up' && - - } - {diffDirection === 'down' && - - } -
- - } + {badge && (diffTooltip ? ( + // Radix Tooltip portals the bubble to body so it escapes + // ancestor overflow/stacking contexts that broke the + // CSS-only hover variant inside narrow rails. + + + + {badge} + + + {diffTooltip} + + + + ) : badge)}
); diff --git a/ghost/core/core/server/api/endpoints/stats.js b/ghost/core/core/server/api/endpoints/stats.js index 2cb9a11789a..af005433e0d 100644 --- a/ghost/core/core/server/api/endpoints/stats.js +++ b/ghost/core/core/server/api/endpoints/stats.js @@ -1,4 +1,7 @@ +const errors = require('@tryghost/errors'); +const labs = require('../../../shared/labs'); const statsService = require('../../services/stats'); +const commentsService = require('../../services/comments'); /** @type {import('@tryghost/api-framework').Controller} */ const controller = { @@ -525,6 +528,31 @@ const controller = { async query(frame) { return await statsService.api.getTopSourcesWithRange(frame.options); } + }, + commentsOverview: { + headers: { + cacheInvalidate: false + }, + options: [ + 'date_from', + 'date_to', + 'timezone' + ], + permissions: { + docName: 'comments', + method: 'browse' + }, + async query(frame) { + if (!labs.isSet('commentAnalytics')) { + throw new errors.NotFoundError(); + } + const overview = await commentsService.stats.getOverview({ + dateFrom: frame?.options?.date_from, + dateTo: frame?.options?.date_to, + timezone: frame?.options?.timezone + }); + return {data: [overview]}; + } } }; diff --git a/ghost/core/core/server/services/comments/comments-stats-service.js b/ghost/core/core/server/services/comments/comments-stats-service.js index b16fdd8fe9b..2745a4b5af0 100644 --- a/ghost/core/core/server/services/comments/comments-stats-service.js +++ b/ghost/core/core/server/services/comments/comments-stats-service.js @@ -1,3 +1,6 @@ +const moment = require('moment-timezone'); +const {getDateBoundaries, applyDateFilter} = require('../stats/utils/date-utils'); + module.exports = class CommentsStatsService { constructor(deps) { this.db = deps.db; @@ -43,4 +46,171 @@ module.exports = class CommentsStatsService { return counts; } + + /** + * Aggregate comment analytics for the moderation dashboard. + * + * @param {object} options + * @param {string} [options.dateFrom] - Inclusive lower bound (YYYY-MM-DD), interpreted in `timezone` + * @param {string} [options.dateTo] - Inclusive upper bound (YYYY-MM-DD), interpreted in `timezone` + * @param {string} [options.timezone='UTC'] - IANA timezone the bounds are expressed in + * @returns {Promise<{totals: object, previousTotals: object|null, series: Array, topPosts: Array, topMembers: Array}>} + */ + async getOverview({dateFrom, dateTo, timezone} = {}) { + const knex = this.db.knex; + const range = this._resolveRange(dateFrom, dateTo, timezone); + const previousRange = this._resolvePreviousRange(dateFrom, dateTo, timezone); + + const [totals, previousTotals, series, topPosts, topMembers] = await Promise.all([ + this._getTotals(knex, range), + previousRange ? this._getTotals(knex, previousRange) : Promise.resolve(null), + this._getSeries(knex, range), + this._getTopPosts(knex, range), + this._getTopMembers(knex, range) + ]); + + return {totals, previousTotals, series, topPosts, topMembers}; + } + + _resolveRange(dateFrom, dateTo, timezone) { + return getDateBoundaries({date_from: dateFrom, date_to: dateTo, timezone}); + } + + _resolvePreviousRange(dateFrom, dateTo, timezone) { + // Length-matched window immediately preceding the current range. Used + // for period-over-period trend comparisons. Skipped when either bound + // is missing (e.g. unbounded "all time" requests), since "previous" + // is undefined without a known length. + if (!dateFrom || !dateTo) { + return null; + } + const tz = timezone || 'UTC'; + const startOfFrom = moment.tz(dateFrom, tz).startOf('day'); + const startOfTo = moment.tz(dateTo, tz).startOf('day'); + const lengthDays = startOfTo.diff(startOfFrom, 'days') + 1; + if (lengthDays <= 0) { + return null; + } + const prevDateTo = startOfFrom.clone().subtract(1, 'day').format('YYYY-MM-DD'); + const prevDateFrom = startOfFrom.clone().subtract(lengthDays, 'days').format('YYYY-MM-DD'); + return getDateBoundaries({date_from: prevDateFrom, date_to: prevDateTo, timezone: tz}); + } + + _applyRange(query, column, {dateFrom, dateTo}) { + applyDateFilter(query, dateFrom, dateTo, column); + return query; + } + + async _getTotals(knex, range) { + const commentsQuery = knex('comments') + .where('status', 'published') + .count({count: '*'}) + .countDistinct({commenters: 'member_id'}); + this._applyRange(commentsQuery, 'comments.created_at', range); + + const reportedQuery = knex('comment_reports') + .countDistinct({reported: 'comment_id'}); + this._applyRange(reportedQuery, 'comment_reports.created_at', range); + + const [commentsRow] = await commentsQuery; + const [reportedRow] = await reportedQuery; + + return { + comments: Number(commentsRow.count) || 0, + commenters: Number(commentsRow.commenters) || 0, + reported: Number(reportedRow.reported) || 0 + }; + } + + async _getSeries(knex, range) { + const commentsQuery = knex('comments') + .where('status', 'published') + .select(knex.raw('DATE(created_at) as date')) + .count({count: '*'}) + .countDistinct({commenters: 'member_id'}) + .groupByRaw('DATE(created_at)') + .orderByRaw('DATE(created_at) ASC'); + this._applyRange(commentsQuery, 'comments.created_at', range); + + const reportsQuery = knex('comment_reports') + .select(knex.raw('DATE(created_at) as date')) + .countDistinct({reported: 'comment_id'}) + .groupByRaw('DATE(created_at)') + .orderByRaw('DATE(created_at) ASC'); + this._applyRange(reportsQuery, 'comment_reports.created_at', range); + + const [commentsRows, reportsRows] = await Promise.all([commentsQuery, reportsQuery]); + + const byDate = new Map(); + for (const row of commentsRows) { + const date = typeof row.date === 'string' ? row.date : this._formatDate(row.date); + byDate.set(date, { + date, + count: Number(row.count) || 0, + commenters: Number(row.commenters) || 0, + reported: 0 + }); + } + for (const row of reportsRows) { + const date = typeof row.date === 'string' ? row.date : this._formatDate(row.date); + const existing = byDate.get(date) || {date, count: 0, commenters: 0, reported: 0}; + existing.reported = Number(row.reported) || 0; + byDate.set(date, existing); + } + + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); + } + + _formatDate(value) { + if (!(value instanceof Date)) { + return String(value); + } + const year = value.getUTCFullYear(); + const month = String(value.getUTCMonth() + 1).padStart(2, '0'); + const day = String(value.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + async _getTopPosts(knex, range, limit = 25) { + const query = knex('comments') + .join('posts', 'posts.id', 'comments.post_id') + .where('comments.status', 'published') + .select('posts.id as id', 'posts.title as title', 'posts.slug as slug') + .count({count: 'comments.id'}) + .groupBy('posts.id', 'posts.title', 'posts.slug') + .orderBy('count', 'desc') + .orderBy('posts.id', 'asc') + .limit(limit); + this._applyRange(query, 'comments.created_at', range); + + const rows = await query; + return rows.map(row => ({ + id: row.id, + title: row.title, + slug: row.slug, + count: Number(row.count) || 0 + })); + } + + async _getTopMembers(knex, range, limit = 25) { + const query = knex('comments') + .join('members', 'members.id', 'comments.member_id') + .where('comments.status', 'published') + .whereNotNull('comments.member_id') + .select('members.id as id', 'members.name as name', 'members.email as email') + .count({count: 'comments.id'}) + .groupBy('members.id', 'members.name', 'members.email') + .orderBy('count', 'desc') + .orderBy('members.id', 'asc') + .limit(limit); + this._applyRange(query, 'comments.created_at', range); + + const rows = await query; + return rows.map(row => ({ + id: row.id, + name: row.name, + email: row.email, + count: Number(row.count) || 0 + })); + } }; diff --git a/ghost/core/core/server/services/comments/index.js b/ghost/core/core/server/services/comments/index.js index 6dab5b4e93b..32fc5f50698 100644 --- a/ghost/core/core/server/services/comments/index.js +++ b/ghost/core/core/server/services/comments/index.js @@ -32,6 +32,7 @@ class CommentsServiceWrapper { const stats = new CommentsStats({db}); + this.stats = stats; this.controller = new CommentsController(this.api, stats); } } diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 64c90124ccd..be0eff550d6 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -173,6 +173,7 @@ module.exports = function apiRoutes() { router.get('/stats/top-sources-growth', mw.authAdminApi, http(api.stats.topSourcesGrowth)); router.post('/stats/posts-visitor-counts', mw.authAdminApi, http(api.stats.postsVisitorCounts)); router.post('/stats/posts-member-counts', mw.authAdminApi, http(api.stats.postsMemberCounts)); + router.get('/stats/comments', mw.authAdminApi, http(api.stats.commentsOverview)); // ## Labels router.get('/labels', mw.authAdminApi, http(api.labels.browse)); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 6313e187c5b..d12154ee1cf 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -50,7 +50,8 @@ const PRIVATE_FEATURES = [ 'indexnow', 'pictureImageFormats', 'smarterCounts', - 'giftSubscriptions' + 'giftSubscriptions', + 'commentAnalytics' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index 1718bad46e6..70414458143 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -12,6 +12,7 @@ Object { "additionalPaymentMethods": true, "adminUIRefresh": true, "automations": true, + "commentAnalytics": true, "commentModeration": true, "customFonts": true, "editorExcerpt": true, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap index 035416eb8df..493e67c714e 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap @@ -130,6 +130,19 @@ Object { } `; +exports[`Stats API Comments overview returns the overview payload with expected shape 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": StringMatching /\\\\d\\+/, + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Stats API Post attribution stats Can fetch attribution stats 1: [body] 1`] = ` Object { "meta": Object {}, diff --git a/ghost/core/test/e2e-api/admin/stats.test.js b/ghost/core/test/e2e-api/admin/stats.test.js index afe743b61c3..9760328ba75 100644 --- a/ghost/core/test/e2e-api/admin/stats.test.js +++ b/ghost/core/test/e2e-api/admin/stats.test.js @@ -374,4 +374,36 @@ describe('Stats API', function () { }); }); }); + + describe('Comments overview', function () { + it('returns the overview payload with expected shape', async function () { + const {body} = await agent + .get('/stats/comments/') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyContentLength, + etag: anyEtag + }); + + assert.ok(Array.isArray(body.stats), 'expected stats array in response'); + assert.equal(body.stats.length, 1, 'expected a single overview object'); + + const overview = body.stats[0]; + assert.ok(overview.totals, 'expected totals'); + assert.equal(typeof overview.totals.comments, 'number'); + assert.equal(typeof overview.totals.commenters, 'number'); + assert.equal(typeof overview.totals.reported, 'number'); + assert.ok('previousTotals' in overview, 'expected previousTotals key'); + assert.ok(Array.isArray(overview.series)); + assert.ok(Array.isArray(overview.topPosts)); + assert.ok(Array.isArray(overview.topMembers)); + }); + + it('accepts date_from and date_to range parameters', async function () { + await agent + .get('/stats/comments/?date_from=2026-01-01&date_to=2026-12-31') + .expectStatus(200); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/comments/comments-stats-service.test.js b/ghost/core/test/unit/server/services/comments/comments-stats-service.test.js new file mode 100644 index 00000000000..3663df2c175 --- /dev/null +++ b/ghost/core/test/unit/server/services/comments/comments-stats-service.test.js @@ -0,0 +1,275 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const CommentsStatsService = require('../../../../../core/server/services/comments/comments-stats-service'); + +function makeQB(resultFn) { + const qb = {}; + const chainable = [ + 'select', 'where', 'whereIn', 'whereNotNull', 'count', 'countDistinct', + 'groupBy', 'groupByRaw', 'orderBy', 'orderByRaw', 'limit', 'join' + ]; + for (const method of chainable) { + qb[method] = sinon.stub().returns(qb); + } + qb.then = (resolve, reject) => Promise.resolve(resultFn(qb)).then(resolve, reject); + qb.catch = fn => Promise.resolve(resultFn(qb)).catch(fn); + return qb; +} + +function createService({tableResults = {}} = {}) { + const captured = {}; + const knex = sinon.stub().callsFake((table) => { + const handler = tableResults[table]; + if (!handler) { + throw new Error(`Unexpected knex table "${table}" in test`); + } + const qbs = captured[table] = captured[table] || []; + const qb = makeQB(builder => handler(builder, qbs.length)); + qbs.push(qb); + return qb; + }); + knex.raw = sinon.stub().callsFake(v => v); + + return { + service: new CommentsStatsService({db: {knex}}), + knex, + captured + }; +} + +describe('CommentsStatsService', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('getOverview', function () { + it('returns zeroed shape when DB has no matching rows', async function () { + const {service} = createService({ + tableResults: { + comments: (builder) => { + if (builder.groupByRaw.called || builder.join.called) { + return []; + } + return [{count: 0, commenters: 0}]; + }, + comment_reports: (builder) => { + if (builder.groupByRaw.called) { + return []; + } + return [{reported: 0}]; + } + } + }); + + const result = await service.getOverview({dateFrom: '2026-01-01', dateTo: '2026-01-31'}); + + assert.deepEqual(result.totals, { + comments: 0, + commenters: 0, + reported: 0 + }); + assert.deepEqual(result.series, []); + assert.deepEqual(result.topPosts, []); + assert.deepEqual(result.topMembers, []); + }); + + it('maps aggregate rows into the expected shape', async function () { + const {service} = createService({ + tableResults: { + comments: (builder) => { + if (builder.groupByRaw.called) { + return [ + {date: '2026-01-10', count: '5', commenters: '4'}, + {date: '2026-01-11', count: '7', commenters: '5'} + ]; + } + if (builder.join.called) { + const joinArgs = builder.join.firstCall.args; + if (joinArgs[0] === 'posts') { + return [ + {id: 'post-1', title: 'Post One', slug: 'post-one', count: '20'}, + {id: 'post-2', title: 'Post Two', slug: 'post-two', count: '15'} + ]; + } + if (joinArgs[0] === 'members') { + return [ + {id: 'mem-1', name: 'Alice', email: 'a@example.com', count: '12'}, + {id: 'mem-2', name: 'Bob', email: 'b@example.com', count: '9'} + ]; + } + } + return [{count: '42', commenters: '11'}]; + }, + comment_reports: (builder) => { + if (builder.groupByRaw.called) { + return [{date: '2026-01-11', reported: '2'}]; + } + return [{reported: '3'}]; + } + } + }); + + const result = await service.getOverview({dateFrom: '2026-01-01', dateTo: '2026-01-31'}); + + assert.deepEqual(result.totals, { + comments: 42, + commenters: 11, + reported: 3 + }); + assert.deepEqual(result.series, [ + {date: '2026-01-10', count: 5, commenters: 4, reported: 0}, + {date: '2026-01-11', count: 7, commenters: 5, reported: 2} + ]); + assert.deepEqual(result.topPosts[0], { + id: 'post-1', title: 'Post One', slug: 'post-one', count: 20 + }); + assert.deepEqual(result.topMembers[0], { + id: 'mem-1', name: 'Alice', email: 'a@example.com', count: 12 + }); + }); + + it('includes reports-only days in the series even when no comments landed that day', async function () { + const {service} = createService({ + tableResults: { + comments: (builder) => { + if (builder.groupByRaw.called || builder.join.called) { + return []; + } + return [{count: 0, commenters: 0}]; + }, + comment_reports: (builder) => { + if (builder.groupByRaw.called) { + return [{date: '2026-02-15', reported: '4'}]; + } + return [{reported: '4'}]; + } + } + }); + + const result = await service.getOverview({}); + + assert.deepEqual(result.series, [ + {date: '2026-02-15', count: 0, commenters: 0, reported: 4} + ]); + }); + + it('returns previousTotals for the equivalent prior window when both bounds are set', async function () { + // The two totals queries fire via Promise.all, so dispatch order is + // non-deterministic. We disambiguate by inspecting the `>=` lower + // bound the service applied to the query (UTC ISO string from + // `getDateBoundaries`). + const isCurrentRange = (builder) => { + const fromCall = builder.where.getCalls().find(call => call.args[1] === '>='); + if (!fromCall) { + return false; + } + return fromCall.args[2] === '2026-02-08T00:00:00.000Z'; + }; + + const {service} = createService({ + tableResults: { + comments: (builder) => { + if (builder.groupByRaw.called || builder.join.called) { + return []; + } + return isCurrentRange(builder) + ? [{count: '40', commenters: '15'}] + : [{count: '20', commenters: '8'}]; + }, + comment_reports: (builder) => { + if (builder.groupByRaw.called) { + return []; + } + return isCurrentRange(builder) + ? [{reported: '6'}] + : [{reported: '3'}]; + } + } + }); + + const result = await service.getOverview({dateFrom: '2026-02-08', dateTo: '2026-02-14'}); + + assert.deepEqual(result.totals, {comments: 40, commenters: 15, reported: 6}); + assert.deepEqual(result.previousTotals, {comments: 20, commenters: 8, reported: 3}); + }); + + it('interprets date bounds in the requested timezone', async function () { + // PST is UTC-8; the start of 2026-02-08 in PST is 08:00 UTC, and + // the end of 2026-02-08 in PST is 2026-02-09T07:59:59.999 UTC. + const recordedBounds = []; + const {service} = createService({ + tableResults: { + comments: (builder) => { + if (builder.groupByRaw.called || builder.join.called) { + return []; + } + const from = builder.where.getCalls().find(c => c.args[1] === '>=')?.args[2]; + const to = builder.where.getCalls().find(c => c.args[1] === '<=')?.args[2]; + recordedBounds.push({from, to}); + return [{count: 0, commenters: 0}]; + }, + comment_reports: () => [{reported: 0}] + } + }); + + await service.getOverview({ + dateFrom: '2026-02-08', + dateTo: '2026-02-08', + timezone: 'America/Los_Angeles' + }); + + const currentBounds = recordedBounds.find(b => b.from === '2026-02-08T08:00:00.000Z'); + assert.ok(currentBounds, 'expected current range lower bound at PST start-of-day in UTC'); + assert.equal(currentBounds.to, '2026-02-09T07:59:59.999Z'); + }); + + it('returns previousTotals = null when range has no bounds', async function () { + const {service} = createService({ + tableResults: { + comments: (builder) => { + if (builder.groupByRaw.called || builder.join.called) { + return []; + } + return [{count: 0, commenters: 0}]; + }, + comment_reports: (builder) => { + if (builder.groupByRaw.called) { + return []; + } + return [{reported: 0}]; + } + } + }); + + const result = await service.getOverview({}); + + assert.equal(result.previousTotals, null); + }); + + it('formats Date instances in series rows into YYYY-MM-DD strings', async function () { + const {service} = createService({ + tableResults: { + comments: (builder) => { + if (builder.groupByRaw.called) { + return [{date: new Date('2026-03-07T00:00:00.000Z'), count: 4, commenters: 3}]; + } + if (builder.join.called) { + return []; + } + return [{count: 0, commenters: 0}]; + }, + comment_reports: (builder) => { + if (builder.groupByRaw.called) { + return []; + } + return [{reported: 0}]; + } + } + }); + + const result = await service.getOverview({}); + + assert.deepEqual(result.series, [{date: '2026-03-07', count: 4, commenters: 3, reported: 0}]); + }); + }); +});