Analytics Content Tab: Top Posts Ranked by Reach Instead of Engagement and Date Range Filter Not Applied
The Analytics Content tab contains two independent bugs in the Top Posts feature.
At a Glance
The Analytics Content tab contains two independent bugs in the Top Posts feature.
Summary
The Analytics Content tab contains two independent bugs in the Top Posts feature. First, the "Top Posts by Engagement" card ranks posts by a total that includes impression/view counts, causing reach to dominate the sort order rather than engagement. Second, getTopPostsByEngagement() is called without a since parameter, so the Top Posts table always reflects all-time data regardless of the date range picker selection. Together, these issues produce misleading metrics and a visible data asymmetry when filtering by a short time window.
—
Root-Cause Analysis
Bug 1: Engagement total includes impressions
Confirmed evidence:
totalExpression()atsrc/lib/db/queries/analytics.ts:312sums every counter a platform exposes, includingimpressions.- Impressions are an order of magnitude larger than engagement counters. A post with 4,210 views and 138 engagements produces a
totalof 4,348, making the sort effectivelyORDER BY views. - The
barKey="total"bar in the UI reflects this inflated total. - The card is explicitly titled "Top Posts by Engagement", creating a direct contradiction between label and behavior.
Confirmed context (deliberate trade-off in PR #218):
- The
totalwas designed to reconcile with the visible row sum:total = sum(all visible columns). Because Views is a visible column, excluding impressions fromtotalwhile retaining the Views column would break that row-sum invariant. The decision was deferred, not overlooked.
Root cause: totalExpression() does not distinguish between reach metrics (impressions/views) and engagement metrics (likes, comments, shares, quotes, etc.) when constructing the sort key and bar value.
—
Bug 2: getTopPostsByEngagement() ignores the since parameter
Confirmed evidence:
src/app/api/analytics/content/route.ts:20callsgetTopPostsByEngagement()with nosinceargument.- Lines
:19(publishedOverTime) and:22(avgMetrics) both receive thesincevalue derived from the range picker. getAverageEngagementMetricsatanalytics.ts:430already filters onem.snapshot_at >= ?, confirming the pattern exists and works.
Practical impact:
- The Top Posts table always returns all-time results regardless of the "Last 7 / 30 / 90 days" selection.
- With platform-grouped layout (introduced in #218), a populated all-time table sits directly above near-empty 7-day stat cards, which appears to the user as a data error rather than a scope mismatch.
Root cause: The since value is not threaded into getTopPostsByEngagement(), and the underlying query contains no time-bound predicate.
—
Related latent issue (not blocking, noted for awareness)
getAverageEngagementMetrics counts every snapshot row in the time range (the field is named snapshots intentionally), while the Top Posts table deduplicates to the latest snapshot per post. These two strategies are currently consistent only because each writer inserts exactly one snapshot per post. When a re-snapshot code path is introduced, averages will inflate relative to per-post values. This is not a current bug but should be addressed before re-snapshotting lands.
—
Resolution Steps
Fix 1: Separate engagement total from reach total
A product decision is required before implementation. Three options, ordered by preference as documented in the issue:
- Recommended — Split the column: Introduce a separate
engagementaggregate that sums only engagement counters (likes, comments, shares, quotes, reposts, etc.) and excludesimpressions. Rank byengagement. Rename the existing column to Views. The row invariant is preserved:Views + Engagement = sum(all visible columns). Update the card title andbarKeyto referenceengagement.
- Alternative — Drop the Views column from the table: Exclude
impressionsfromtotal, drop the Views column from the table (it is already surfaced in the Avg Views stat card), and rank by the correctedtotal. Fewer UI changes but loses per-post view data from the table.
- Fallback — Retitle only: If neither column change is acceptable, rename the card to "Top Posts by Reach" or "Top Posts by Total Activity" to match the actual sort behavior. This is a documentation fix, not a data fix.
Implementation steps for Option 1 (recommended):
- In
src/lib/db/queries/analytics.ts, add a newengagementExpression()function that sums only engagement counters, excludingimpressions. Use the existinggetAverageEngagementMetricsfilter logic as a reference for which fields qualify as engagement. - Add
engagementas a computed column ingetTopPostsByEngagement()alongside the existingtotal(now representing Views + Engagement sum, or repurposed as Views alone). - Change the
ORDER BYclause to sort byengagement DESC. - Update the API response shape and the frontend component to use
barKey="engagement"and render the split columns. - Update the card title to "Top Posts by Engagement" (or confirm it remains correct).
—
Fix 2: Thread since into getTopPostsByEngagement()
- Update the function signature in
src/lib/db/queries/analytics.tsto accept an optionalsince: string | Date | undefinedparameter, matching the pattern used bygetAverageEngagementMetrics.
- Add the time predicate to the query. Decide which timestamp to filter on:
The snapshot-time filter is recommended for consistency and to allow surfacing older posts that had recent engagement spikes.
em.snapshot_at >= ?— answers "which posts had recorded activity in this window?" (consistent withgetAverageEngagementMetrics).p.published_at >= ?— answers "which posts were published in this window?".
- Pass
sinceat the call site insrc/app/api/analytics/content/route.ts:20.
—
CLI Commands
Locate all call sites of getTopPostsByEngagement to audit parameter passing:
grep -rn "getTopPostsByEngagement" src/Confirm the current query structure and identify all aggregate expressions:
grep -n "totalExpression\|impressions\|snapshot_at\|published_at" src/lib/db/queries/analytics.tsCheck the API route for parameter threading:
grep -n "since\|getTopPostsByEngagement\|publishedOverTime\|avgMetrics" src/app/api/analytics/content/route.ts—
Configuration Snippets
Corrected API route call site (route.ts)
// Before
const topPosts = await getTopPostsByEngagement(userId, platform);
// After — thread since to match publishedOverTime and avgMetrics
const topPosts = await getTopPostsByEngagement(userId, platform, since);Engagement-only aggregate in analytics.ts (Option 1 sketch)
// Add alongside or replacing totalExpression()
function engagementExpression(platform: Platform): SQL {
// Sum only engagement counters — exclude impressions/views
// Adjust field list to match actual schema columns per platform
return sql`(
COALESCE(em.likes, 0) +
COALESCE(em.comments, 0) +
COALESCE(em.shares, 0) +
COALESCE(em.reposts, 0) +
COALESCE(em.quotes, 0)
)`;
}> Note: Replace the field list with the actual engagement column names for each platform as defined in your schema. Do not include impressions, views, or equivalent reach columns.
Time predicate addition in getTopPostsByEngagement()
// Mirror the pattern from getAverageEngagementMetrics (analytics.ts:430)
...(since ? [sql`em.snapshot_at >= ${since}`] : [])—
Verification
Verify Bug 1 fix (engagement ranking)
- Open the Analytics Content tab.
- Find a post with high views but low engagement (e.g., 4,210 views, 138 engagements).
- Confirm it no longer appears at the top of "Top Posts by Engagement" if other posts have higher engagement counts.
- Confirm the
totalorengagementbar value shown matches only engagement counters (likes + comments + shares + etc.), not the view count.
# Query the DB directly to confirm sort order
# Replace <USER_ID> and <PLATFORM> with real values
sqlite3 <DB_PATH> "
SELECT post_id, SUM(likes + comments + shares + reposts + quotes) AS engagement,
SUM(impressions) AS views
FROM engagement_metrics
GROUP BY post_id
ORDER BY engagement DESC
LIMIT 10;
"Verify Bug 2 fix (date range respected)
- Select "Last 7 days" in the range picker.
- Confirm the Top Posts table changes to show only posts with snapshot activity within the last 7 days.
- Switch to "Last 90 days" — confirm the table expands accordingly.
- On a 7-day range over older content, the Top Posts table should now be empty or sparse, consistent with the near-empty stat cards above it.
# Confirm the API request includes `since` for topPosts
# Check network requests in browser DevTools or inspect server logs for the route handler
grep -n "since" src/app/api/analytics/content/route.tsRollback indicator: If the Top Posts table becomes unexpectedly empty after the fix on a range that previously showed results, verify that since is being serialized and passed correctly (check for timezone or ISO string formatting issues).
—
Prevention
- Add a query contract test: Write an integration test for
getTopPostsByEngagement()that inserts posts with known view and engagement counters, then asserts the sort order matches engagement rank, not view rank.
- Add a parameter coverage test for the API route: Assert that all three data-fetching calls in
route.ts(publishedOverTime,topPosts,avgMetrics) receive the samesincevalue when a range is provided. A missing parameter to any one of them should be caught at CI time.
- Lint rule or code review checklist: When adding a new data-fetching call to a range-aware API route, require explicit handling of
since(either pass it or document why it is intentionally excluded).
- Metric label validation: Add a frontend test or Storybook story that asserts the
barKeyprop value and the card title are consistent. A bar keyed to a field namedtotalon a card titled "by Engagement" should fail a snapshot or label-matching check.
- Document the row-sum invariant: Add a comment in
totalExpression()andengagementExpression()explicitly stating which metric categories each aggregate includes, so future contributors understand why impressions are excluded and do not re-add them.
- Track the snapshot-deduplication divergence: File a separate task to align
getAverageEngagementMetrics(currently snapshot-count-weighted) with the per-post deduplication strategy used in the Top Posts table before any re-snapshot path is merged.