Skip to content

feat(search-performance): add page-path and metric filters to GSC Insights#83

Open
shashank-sn wants to merge 2 commits into
every-app:mainfrom
shashank-sn:feat/gsc-insights-path-metric-filters
Open

feat(search-performance): add page-path and metric filters to GSC Insights#83
shashank-sn wants to merge 2 commits into
every-app:mainfrom
shashank-sn:feat/gsc-insights-path-metric-filters

Conversation

@shashank-sn

@shashank-sn shashank-sn commented Jul 13, 2026

Copy link
Copy Markdown

Summary

GSC Insights (Search Performance) now supports path-prefix filtering and min/max ranges for impressions, clicks, and average position. Users can isolate sections like /blogs/* and focus on rows above meaningful thresholds without scanning the full table manually.

Path filters are pushed to GSC as page contains filters. Metric ranges are applied server-side after fetch and before pagination/export, so table pages, exports, and striking-distance rows share the same effective filter set. Overview totals honor path/device/country filters; when metric ranges are active, the UI explains that totals exclude those bounds and notes the 1,000-row cap for filtered tables/exports.

Validation

  • pnpm run ci:check — pass (prettier, knip, tsc, oxlint)
  • pnpm run test:ci — 632 tests pass

Manual QA with a connected GSC property is recommended for filter UX and export parity.

Test plan

  • Set page path /blogs/ or /blogs/* and confirm queries/pages/striking distance narrow to matching URLs
  • Set min impressions/clicks/position and confirm table, pagination, and CSV/Sheets export match
  • Enter invalid metric values and confirm inline errors; valid path filter still applies
  • With metric filters active, confirm overview disclaimer and 1,000-row cap note appear
  • Clear filters resets table and striking distance

Fixes #68

Greptile Summary

This PR adds page-path prefix filtering and min/max metric ranges (impressions, clicks, average position) to the GSC Search Performance feature. Path filters are pushed to GSC as page contains filters; metric filters are applied server-side after a capped 1,000-row fetch, with consistent behavior across the overview, paginated table, and CSV/Sheets export.

  • New searchPerformanceFilters.ts module extracts pure filter/pagination helpers (normalizePagePathFilter, matchesMetricFilters, paginateRows, etc.) with good unit-test coverage.
  • getSearchPerformanceTable gains a metric-filter branch that fetches up to 1,000 rows from GSC, filters in-app, then paginates; without metric filters the original GSC-side startRow pagination is preserved.
  • fetchFilteredDimensionRows is a shared helper used by both the table and export paths, but its parameter type omits device and country even though buildGscFilters reads them from the object at runtime — making the device/country filtering implicit and unenforceable by TypeScript.

Confidence Score: 3/5

The filtering logic is well-tested in isolation, but the shared fetchFilteredDimensionRows helper has a type contract that doesn't capture all of its dependencies, which could silently break device/country filtering if the function is reused or refactored.

The core concern is fetchFilteredDimensionRows: it passes its data argument to buildGscFilters, which reads device and country from the object at runtime, but those fields are absent from the function's declared parameter type. This works today because both callers pass the full validated request, but TypeScript cannot enforce that invariant. A future caller passing only metric-filter fields would silently produce unscoped GSC queries. The query-key issue (raw form strings instead of compiled filters) is a secondary concern that can cause unnecessary cache churn when users type numeric values.

src/serverFunctions/searchPerformance.ts — the fetchFilteredDimensionRows signature and its implicit dependency on runtime properties not reflected in its type.

Important Files Changed

Filename Overview
src/serverFunctions/searchPerformance.ts Adds metric-filter branch to getSearchPerformanceTable and refactors export to use fetchFilteredDimensionRows; the helper's type signature omits device/country even though buildGscFilters reads them at runtime, creating a hidden contract.
src/types/schemas/search-performance.ts Adds Zod metric-filter fields, refineMetricRanges cross-field validation, and hasActiveMetricFilters; SearchPerformanceMetricFilters is a manually-maintained parallel type instead of being derived via z.infer.
src/client/features/search-performance/SearchPerformancePage.tsx Wires advanced filter state into queries, prefetch, and export; query key uses raw form string state rather than the compiled canonical filter object, risking unnecessary cache misses.
src/server/features/gsc/searchPerformanceFilters.ts New module with well-tested, pure filter/pagination helpers: normalizePagePathFilter, toPagePathGscFilter, matchesMetricFilters, filterDimensionRowsByMetrics, filterStrikingDistanceByMetrics, and paginateRows.
src/client/features/search-performance/SearchPerformanceAdvancedFilters.tsx New UI component with client-side validation, metric range fields, and filter compilation; logic is flat, well-tested, and mixes no server concerns.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as SearchPerformancePage
    participant SF as searchPerformance (serverFn)
    participant Filters as searchPerformanceFilters
    participant GSC as GscService

    UI->>SF: "getSearchPerformanceReport({ pagePath, device, country, ... })"
    SF->>SF: buildGscFilters(data) → deviceFilters + filters (incl. pagePath)
    SF->>GSC: getPerformance(filters) × 3 (current, previous, queryPages)
    GSC-->>SF: raw rows
    SF->>Filters: filterStrikingDistanceByMetrics(rows, metricFilters)
    SF-->>UI: "{ totals, strikingDistance, metricFiltersActive }"

    UI->>SF: "getSearchPerformanceTable({ page, pageSize, metric filters... })"
    alt hasActiveMetricFilters
        SF->>Filters: fetchFilteredDimensionRows(data, projectId)
        Filters->>GSC: "getPerformance(filters, rowLimit=1000)"
        GSC-->>Filters: up to 1000 rows
        Filters->>Filters: filterDimensionRowsByMetrics(rows, metricFilters)
        Filters->>Filters: paginateRows(filtered, page, pageSize)
        Filters-->>SF: "{ rows, hasNextPage }"
    else no metric filters
        SF->>GSC: "getPerformance(filters, startRow=offset, rowLimit=pageSize+1)"
        GSC-->>SF: page rows
    end
    SF-->>UI: "{ rows, hasNextPage }"

    UI->>SF: "exportSearchPerformanceTable({ dimension, filters... })"
    SF->>Filters: fetchFilteredDimensionRows(data, projectId)
    Filters->>GSC: "getPerformance(filters, rowLimit=1000)"
    GSC-->>Filters: rows
    Filters->>Filters: filterDimensionRowsByMetrics
    SF-->>UI: "{ dimension, rows }"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as SearchPerformancePage
    participant SF as searchPerformance (serverFn)
    participant Filters as searchPerformanceFilters
    participant GSC as GscService

    UI->>SF: "getSearchPerformanceReport({ pagePath, device, country, ... })"
    SF->>SF: buildGscFilters(data) → deviceFilters + filters (incl. pagePath)
    SF->>GSC: getPerformance(filters) × 3 (current, previous, queryPages)
    GSC-->>SF: raw rows
    SF->>Filters: filterStrikingDistanceByMetrics(rows, metricFilters)
    SF-->>UI: "{ totals, strikingDistance, metricFiltersActive }"

    UI->>SF: "getSearchPerformanceTable({ page, pageSize, metric filters... })"
    alt hasActiveMetricFilters
        SF->>Filters: fetchFilteredDimensionRows(data, projectId)
        Filters->>GSC: "getPerformance(filters, rowLimit=1000)"
        GSC-->>Filters: up to 1000 rows
        Filters->>Filters: filterDimensionRowsByMetrics(rows, metricFilters)
        Filters->>Filters: paginateRows(filtered, page, pageSize)
        Filters-->>SF: "{ rows, hasNextPage }"
    else no metric filters
        SF->>GSC: "getPerformance(filters, startRow=offset, rowLimit=pageSize+1)"
        GSC-->>SF: page rows
    end
    SF-->>UI: "{ rows, hasNextPage }"

    UI->>SF: "exportSearchPerformanceTable({ dimension, filters... })"
    SF->>Filters: fetchFilteredDimensionRows(data, projectId)
    Filters->>GSC: "getPerformance(filters, rowLimit=1000)"
    GSC-->>Filters: rows
    Filters->>Filters: filterDimensionRowsByMetrics
    SF-->>UI: "{ dimension, rows }"
Loading

Comments Outside Diff (1)

  1. src/client/features/search-performance/SearchPerformancePage.tsx, line 476-485 (link)

    P2 Query key uses raw form strings instead of compiled canonical filters

    advancedFilters in the query key holds raw string values (e.g. "100", "100.0", "abc"). Two form states that compile to identical SearchPerformanceMetricFilters — such as "10" vs "10.0", or a partially invalid form that compiles the same as an empty one — produce different cache entries and trigger separate network calls. Using compileAdvancedSearchPerformanceFilters(advancedFilters) as the key value gives TanStack Query a stable semantic identity that matches what the server actually receives.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/client/features/search-performance/SearchPerformancePage.tsx
    Line: 476-485
    
    Comment:
    **Query key uses raw form strings instead of compiled canonical filters**
    
    `advancedFilters` in the query key holds raw string values (e.g. `"100"`, `"100.0"`, `"abc"`). Two form states that compile to identical `SearchPerformanceMetricFilters` — such as `"10"` vs `"10.0"`, or a partially invalid form that compiles the same as an empty one — produce different cache entries and trigger separate network calls. Using `compileAdvancedSearchPerformanceFilters(advancedFilters)` as the key value gives TanStack Query a stable semantic identity that matches what the server actually receives.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex Fix in Cursor

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/serverFunctions/searchPerformance.ts:74-94
**`device` and `country` silently dropped when type is narrowed**

`fetchFilteredDimensionRows` types `data` as `SearchPerformanceMetricFilters & { dateRange, dimension }`, which has no `device` or `country`. Inside, `buildGscFilters(data)` is called — it accepts those fields as optional, so TypeScript doesn't complain. Today it works because both callers pass the full validated request object, so the runtime value carries `device` and `country` even though the TypeScript type doesn't declare them. If the function is ever called with a genuinely narrower object (or if `buildGscFilters` is updated to check for the field's presence), those filters silently won't apply and the GSC query will return unfiltered data. The type should declare `device?: string; country?: string;` so the contract is explicit.

### Issue 2 of 3
src/types/schemas/search-performance.ts:82-90
**Manual type duplicates the Zod shape**

`SearchPerformanceMetricFilters` is a hand-written parallel of `searchPerformanceMetricFilterShape`. The project rule is to derive TypeScript types from Zod schemas with `z.infer` rather than maintaining a second definition. The shape can be wrapped in a temporary `z.object` for inference: `type SearchPerformanceMetricFilters = z.infer<ReturnType<typeof z.object<typeof searchPerformanceMetricFilterShape>>>`. If the schema's transform logic or optional fields change later, the manual type will silently drift.
- Remember that these are external ... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

### Issue 3 of 3
src/client/features/search-performance/SearchPerformancePage.tsx:476-485
**Query key uses raw form strings instead of compiled canonical filters**

`advancedFilters` in the query key holds raw string values (e.g. `"100"`, `"100.0"`, `"abc"`). Two form states that compile to identical `SearchPerformanceMetricFilters` — such as `"10"` vs `"10.0"`, or a partially invalid form that compiles the same as an empty one — produce different cache entries and trigger separate network calls. Using `compileAdvancedSearchPerformanceFilters(advancedFilters)` as the key value gives TanStack Query a stable semantic identity that matches what the server actually receives.

Reviews (1): Last reviewed commit: "feat(search-performance): add page-path ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

…ights

Users can filter Search Performance by path prefix and impression, click,
and position ranges. Path filters push to GSC; metric thresholds apply
server-side before pagination and export.

Fixes every-app#68
Comment on lines +74 to +94
async function fetchFilteredDimensionRows(
data: SearchPerformanceMetricFilters & {
dateRange: SearchPerformanceDateRange;
dimension: SearchPerformanceTableDimension;
},
contextProjectId: string,
) {
const { startDate, endDate } = resolveDateRange({
dateRange: data.dateRange,
});
const { filters } = buildGscFilters(data);
const result = await GscService.getPerformance({
projectId: contextProjectId,
startDate,
endDate,
dimensions: [data.dimension],
filters,
rowLimit: EXPORT_ROW_LIMIT,
});
return filterDimensionRowsByMetrics(toDimensionRows(result.rows), data);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 device and country silently dropped when type is narrowed

fetchFilteredDimensionRows types data as SearchPerformanceMetricFilters & { dateRange, dimension }, which has no device or country. Inside, buildGscFilters(data) is called — it accepts those fields as optional, so TypeScript doesn't complain. Today it works because both callers pass the full validated request object, so the runtime value carries device and country even though the TypeScript type doesn't declare them. If the function is ever called with a genuinely narrower object (or if buildGscFilters is updated to check for the field's presence), those filters silently won't apply and the GSC query will return unfiltered data. The type should declare device?: string; country?: string; so the contract is explicit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/serverFunctions/searchPerformance.ts
Line: 74-94

Comment:
**`device` and `country` silently dropped when type is narrowed**

`fetchFilteredDimensionRows` types `data` as `SearchPerformanceMetricFilters & { dateRange, dimension }`, which has no `device` or `country`. Inside, `buildGscFilters(data)` is called — it accepts those fields as optional, so TypeScript doesn't complain. Today it works because both callers pass the full validated request object, so the runtime value carries `device` and `country` even though the TypeScript type doesn't declare them. If the function is ever called with a genuinely narrower object (or if `buildGscFilters` is updated to check for the field's presence), those filters silently won't apply and the GSC query will return unfiltered data. The type should declare `device?: string; country?: string;` so the contract is explicit.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

Tighten fetchFilteredDimensionRows types, derive metric filters from Zod,
and stabilize query keys/prefetch deps with compiled filter state.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add page-path and metric filters to GSC Insights

1 participant