From cb84f059f6c0090581172d93aa61de64b527b5ea Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Thu, 5 Nov 2026 12:21:32 +0530 Subject: [PATCH 1/2] Trend column shows search volume, not rank Tracked keyword metrics now carry monthly_searches (the provider's months behind the trend, oldest first). The Trend column draws the last 12 months as a sparkline, higher volume higher up, on each row's own scale, with a month without a volume breaking the line. The rows no longer ask for include=sparkline. The design preview, the API contract and the docs follow. --- apps/web/design-preview/kw-grouped-model.js | 34 ++++- apps/web/design-preview/kw-grouped.js | 17 +-- apps/web/messages/en.json | 11 +- .../src/components/app/data-table/columns.tsx | 2 +- .../app/sparkline/sparkline-cell.test.tsx | 49 ++----- .../app/sparkline/sparkline-cell.tsx | 12 +- .../features/rankings/rank-columns.test.tsx | 136 ++++++------------ .../src/features/rankings/rank-columns.tsx | 61 ++++---- .../src/features/rankings/rankings-queries.ts | 12 +- .../src/features/rankings/rankings.test.tsx | 10 +- apps/web/src/lib/api/schema.ts | 5 + apps/web/src/lib/charts/sparkline.test.ts | 76 +++++----- apps/web/src/lib/charts/sparkline.ts | 61 ++++---- apps/web/src/mocks/keyword-metrics.ts | 1 + apps/web/src/test/api-shapes.ts | 1 + backend/openapi/v1.json | 11 +- .../src/idraft/api/keywords/metric_values.py | 15 +- backend/src/idraft/api/schemas/keywords.py | 16 ++- .../api/keywords/test_tracked_keywords.py | 4 + docs/19-api-specification.md | 4 +- docs/20-frontend-specification.md | 4 +- docs/design-system/15-data-visualization.md | 1 + docs/design-system/19-responsive-behavior.md | 2 +- docs/design-system/20-accessibility.md | 2 +- docs/design-system/23-application-patterns.md | 2 +- docs/design-system/24-page-templates.md | 2 +- docs/design-system/25-component-inventory.md | 4 +- 27 files changed, 258 insertions(+), 297 deletions(-) diff --git a/apps/web/design-preview/kw-grouped-model.js b/apps/web/design-preview/kw-grouped-model.js index 3358ed2f..a9a39b1d 100644 --- a/apps/web/design-preview/kw-grouped-model.js +++ b/apps/web/design-preview/kw-grouped-model.js @@ -4,7 +4,7 @@ * Rankings table has them, with their widths), the numbers of a set of keywords for the devices chosen, the filters, * and the two export formats. */ -import { UNGROUPED, groupNames, series, store, usedCountries } from './kw-grouped-data.js'; +import { UNGROUPED, groupNames, store, usedCountries } from './kw-grouped-data.js'; export const GROUP_LABELS = { order: 'Order of the groups' }; export const ALL_COUNTRIES = 'All countries'; @@ -152,7 +152,6 @@ export function measures(items) { const kds = items.map((keyword) => keyword.kd).filter((kd) => kd !== null); const cpcs = items.map((keyword) => keyword.cpc).filter((cpc) => cpc > 0); const moves = items.flatMap((keyword) => devices.map((device) => gained(keyword, device))); - const ranked = ranks.filter(isRank); return { count: new Set(items.map((keyword) => keyword.keyword)).size, countries: new Set(items.map((keyword) => keyword.country)).size, @@ -171,10 +170,28 @@ export function measures(items) { up: moves.filter((move) => move > 0).length, down: moves.filter((move) => move < 0).length, cited: items.filter((keyword) => derived(keyword).aio.kind === 'cited').length, - trend: series(ranked.length === 0 ? 40 : sum(ranked) / ranked.length, items.length), + // The months of the group's search volume: the sum of its keywords' months + trend: Array.from({ length: MONTHS }, (_, month) => sum(items.map((keyword) => monthly(keyword)[month]))), }; } +const MONTHS = 12; + +/** + * The last twelve months of a keyword's search volume, oldest first, averaging its volume: a gentle slope up or + * down and, for about a quarter of the keywords, a season. Derived from the text, so it is the same on every visit. + */ +export function monthly(keyword) { + const seed = hash(keyword.keyword) / 4294967295; + const slope = (seed - 0.5) * 0.04; + const swing = seed > 0.75 ? 0.6 : 0.12; + const peak = Math.floor(seed * 1000) % MONTHS; + return Array.from({ length: MONTHS }, (_, month) => { + const season = 1 + swing * Math.cos(((month - peak) / MONTHS) * 2 * Math.PI); + return Math.max(0, Math.round(keyword.volume * season * (1 + slope * (month - (MONTHS - 1) / 2)))); + }); +} + // ------------------------------------------------------------------------------------------------- // Which keywords the page shows @@ -273,14 +290,19 @@ export function csvText(rows) { return `${String.fromCharCode(0xfeff)}${rows.map((row) => row.map(field).join(',')).join('\r\n')}\r\n`; } -/** A line of a series, drawn so that up is better (a smaller rank is higher). */ -export function sparkline(points, label, className = 'w-18 h-5.5 text-primary') { +/** + * A line of a series on a scale of its own lowest and highest point. By default up is better (a smaller rank is + * higher); with `higherIsUp` a larger value is higher, as for a search volume. + */ +export function sparkline(points, label, className = 'w-18 h-5.5 text-primary', higherIsUp = false) { const width = 72; const height = 22; const high = Math.max(...points); const low = Math.min(...points); const span = high - low || 1; - const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${((index / (points.length - 1)) * width).toFixed(1)} ${(((point - low) / span) * (height - 4) + 2).toFixed(1)}`).join(' '); + // A series that never moves sits in the middle + const share = (point) => (high === low ? 0.5 : (higherIsUp ? high - point : point - low) / span); + const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${((index / (points.length - 1)) * width).toFixed(1)} ${(share(point) * (height - 4) + 2).toFixed(1)}`).join(' '); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.setAttribute('class', className); diff --git a/apps/web/design-preview/kw-grouped.js b/apps/web/design-preview/kw-grouped.js index 49bfad11..61eb979b 100644 --- a/apps/web/design-preview/kw-grouped.js +++ b/apps/web/design-preview/kw-grouped.js @@ -7,11 +7,11 @@ * selection with bulk actions, the menus of rows and groups, and the drawers. A reload starts from the fixtures * again. Reached at #/keywords, the page the sidebar's Keywords links to. */ -import { UNGROUPED, archiveKeywords, deleteKeywords, series, store, usedCountries } from './kw-grouped-data.js'; +import { UNGROUPED, archiveKeywords, deleteKeywords, store, usedCountries } from './kw-grouped-data.js'; import { BUTTON_SM, icon } from './perf-ui.js'; import { confirmDialog } from './kw-dialog.js'; import { el } from './prompts-parts.js'; -import { ALL_COUNTRIES, addScope, best, columnsFor, clicks, impressions, derived, gained, groupOf, isRank, measures, plural, rankText, refresh, setRenderer, shown, sparkline, state, volumeText } from './kw-grouped-model.js'; +import { ALL_COUNTRIES, addScope, best, columnsFor, clicks, impressions, derived, gained, groupOf, isRank, measures, monthly, plural, rankText, refresh, setRenderer, shown, sparkline, state, volumeText } from './kw-grouped-model.js'; import { openAdd, openMove } from './kw-grouped-dialogs.js'; import { openGroup, openKeyword, redrawDrawer } from './kw-grouped-drawer.js'; import { bindPages, closePages, pagesOf } from './kw-grouped-pages.js'; @@ -72,15 +72,16 @@ function featuresCell(info) { return row; } +/** The keyword's search volume over the last 12 months, whatever its rank: the column is about demand, not position. */ function trendCell(keyword) { - const top = best(keyword); - if (pending(keyword)) return muted('n/a'); - if (top === Infinity) { + if (keyword.volume === 0) { const line = el('span', 'block h-px w-16 border-t border-dashed border-border-strong'); - line.setAttribute('aria-label', 'Over 100 for 30 days'); + line.setAttribute('aria-label', 'Search volume trend: no monthly volumes yet'); return line; } - return sparkline(series(top, keyword.keyword.length + (state.device === 'mobile' ? 7 : 0)), `Rank over 30 days, now ${top}`); + const months = monthly(keyword); + const text = (value) => value.toLocaleString('en-US'); + return sparkline(months, `Search volume trend, last 12 months: from ${text(months[0])} to ${text(months.at(-1))}, peak ${text(Math.max(...months))}`, undefined, true); } function menuButton(label, attach) { @@ -194,7 +195,7 @@ function groupRow(group, items, columns) { intent: () => td(), features: () => td(), aio: () => td(m.cited > 0 ? twoLine(String(m.cited), 'cited') : muted('n/a')), - trend: () => td(sparkline(m.trend, `Average rank of ${group.name} over 30 days`)), + trend: () => td(sparkline(m.trend, `Search volume of ${group.name}, last 12 months`, undefined, true)), priority: () => td(), group: () => td(), tags: () => td(), diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f45f9216..fad7e4bf 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2414,13 +2414,10 @@ }, "trend": { "column": "Trend", - "label": "Rank trend, last {checks, plural, one {check} other {# checks}}: from {first} to {last}, best {best}", - "labelBeyond": "{count, plural, one {1 check} other {# checks}} beyond the depth checked", - "labelNone": "Rank trend: not ranked within the depth in the last {checks, plural, one {check} other {# checks}}", - "labelEmpty": "Rank trend: no checks yet", - "labelFailed": "Rank trend: checks failed", - "tipTitle": "Last checks", - "tipPoint": "{date}: {rank}" + "label": "Search volume trend, last {months, plural, one {month} other {# months}}: from {first} to {last}, peak {peak}", + "labelEmpty": "Search volume trend: no monthly volumes yet", + "tipTitle": "Monthly search volume", + "tipPoint": "{month}: {volume}" }, "strip": { "label": "Summary for the filtered keywords", diff --git a/apps/web/src/components/app/data-table/columns.tsx b/apps/web/src/components/app/data-table/columns.tsx index c751e5b0..20a9be13 100644 --- a/apps/web/src/components/app/data-table/columns.tsx +++ b/apps/web/src/components/app/data-table/columns.tsx @@ -316,7 +316,7 @@ export const col = { return rich(id, 'aiOverview', sortValue, options); }, - /** Rank trend sparkline: canvas, left aligned (15-data-visualization.md section 9). Never sorts. */ + /** Search volume trend sparkline: canvas, left aligned (15-data-visualization.md section 9). Never sorts. */ sparkline(id: string, options: RichOptions): DataTableColumn { return rich(id, 'sparkline', () => null, { ...options, sortable: false }); }, diff --git a/apps/web/src/components/app/sparkline/sparkline-cell.test.tsx b/apps/web/src/components/app/sparkline/sparkline-cell.test.tsx index 280c123b..bfeabde3 100644 --- a/apps/web/src/components/app/sparkline/sparkline-cell.test.tsx +++ b/apps/web/src/components/app/sparkline/sparkline-cell.test.tsx @@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SparklineCell } from './sparkline-cell'; -import type { SparklinePoint } from '@/lib/api/types'; describe('SparklineCell', () => { const contextSpies = { @@ -27,12 +26,8 @@ describe('SparklineCell', () => { }); it('renders a canvas with role="img", aria-label, and scaled pixel dimensions', () => { - const points: SparklinePoint[] = [ - { t: '2026-10-01T00:00:00Z', rank: 3, depth: 20, state: 'ranked' }, - { t: '2026-10-02T00:00:00Z', rank: 4, depth: 20, state: 'ranked' }, - ]; - render(); - const canvas = screen.getByRole('img', { name: 'Rank trend: from 3 to 4, best 3' }); + render(); + const canvas = screen.getByRole('img', { name: 'Search volume trend: from 1,300 to 1,900' }); expect(canvas.tagName.toLowerCase()).toBe('canvas'); expect(canvas).toHaveAttribute('width', String(64 * (window.devicePixelRatio || 1))); expect(canvas).toHaveAttribute('height', String(20 * (window.devicePixelRatio || 1))); @@ -41,46 +36,30 @@ describe('SparklineCell', () => { expect(contextSpies.arc).toHaveBeenCalledTimes(1); }); - it('breaks into multiple multi-point segments with no arc if the last check was beyond depth', () => { - const points: SparklinePoint[] = [ - { t: '2026-10-01T00:00:00Z', rank: 3, depth: 20, state: 'ranked' }, - { t: '2026-10-02T00:00:00Z', rank: 4, depth: 20, state: 'ranked' }, - { t: '2026-10-03T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - ]; - render(); + it('breaks the line at a month without a volume, and puts no dot on a last month that has none', () => { + render(); expect(contextSpies.moveTo).toHaveBeenCalledTimes(1); expect(contextSpies.lineTo).toHaveBeenCalledTimes(1); expect(contextSpies.arc).not.toHaveBeenCalled(); }); - it('draws a dot for a one-point segment in [beyond, ranked, beyond]', () => { - const points: SparklinePoint[] = [ - { t: '2026-10-01T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - { t: '2026-10-02T00:00:00Z', rank: 7, depth: 20, state: 'ranked' }, - { t: '2026-10-03T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - ]; - render(); + it('draws a dot for a run of one month in [none, volume, none]', () => { + render(); expect(contextSpies.arc).toHaveBeenCalledTimes(1); expect(contextSpies.fill).toHaveBeenCalledTimes(1); }); - it('renders dashed treatment when there are no segments [beyond, beyond]', () => { - const points: SparklinePoint[] = [ - { t: '2026-10-01T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - { t: '2026-10-02T00:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - ]; - const { container } = render(); - const emptyWrapper = screen.getByRole('img', { name: 'Rank trend: not ranked within the depth' }); + it('renders the dashed treatment when no month has a volume', () => { + const { container } = render(); + const emptyWrapper = screen.getByRole('img', { name: 'Search volume trend: none' }); expect(emptyWrapper.tagName.toLowerCase()).toBe('span'); - const line = container.querySelector('.border-border'); - expect(line).toBeInTheDocument(); + expect(container.querySelector('.border-border')).toBeInTheDocument(); }); - it('renders empty state when there are no points', () => { - const { container } = render(); - const emptyWrapper = screen.getByRole('img', { name: 'Rank trend: no checks yet' }); + it('renders the dashed treatment when there are no months', () => { + const { container } = render(); + const emptyWrapper = screen.getByRole('img', { name: 'Search volume trend: no monthly volumes yet' }); expect(emptyWrapper.tagName.toLowerCase()).toBe('span'); - const line = container.querySelector('.border-border'); - expect(line).toBeInTheDocument(); + expect(container.querySelector('.border-border')).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/app/sparkline/sparkline-cell.tsx b/apps/web/src/components/app/sparkline/sparkline-cell.tsx index 90a15ba3..14967ec2 100644 --- a/apps/web/src/components/app/sparkline/sparkline-cell.tsx +++ b/apps/web/src/components/app/sparkline/sparkline-cell.tsx @@ -2,26 +2,26 @@ import { useEffect, useMemo, useRef, type ReactElement } from 'react'; -import type { SparklinePoint } from '@/lib/api/types'; -import { SPARK_HEIGHT, SPARK_WIDTH, sparklineGeometry } from '@/lib/charts/sparkline'; +import { SPARK_HEIGHT, SPARK_WIDTH, sparklineGeometry, type SparkValue } from '@/lib/charts/sparkline'; import { readColorToken } from '@/lib/design/read-tokens'; import { cn } from '@/lib/utils'; export interface SparklineCellProps { - points: readonly SparklinePoint[]; + /** The monthly search volumes, oldest first; `null` for a month without one. */ + values: readonly SparkValue[]; label: string; className?: string | undefined; } /** - * A rank trend sparkline in a table cell (15-data-visualization.md section 9). + * A search volume trend sparkline in a table cell (15-data-visualization.md section 9). * Draws on a 64 x 20 canvas with role="img" and accessible label. * An empty sparkline renders a 1 px dashed line across the width. */ -export function SparklineCell({ points, label, className }: SparklineCellProps): ReactElement { +export function SparklineCell({ values, label, className }: SparklineCellProps): ReactElement { const canvasRef = useRef(null); - const geo = useMemo(() => sparklineGeometry(points, SPARK_WIDTH, SPARK_HEIGHT), [points]); + const geo = useMemo(() => sparklineGeometry(values, SPARK_WIDTH, SPARK_HEIGHT), [values]); const hasSegments = geo.segments.length > 0; useEffect(() => { diff --git a/apps/web/src/features/rankings/rank-columns.test.tsx b/apps/web/src/features/rankings/rank-columns.test.tsx index 14366c95..b21dbaf7 100644 --- a/apps/web/src/features/rankings/rank-columns.test.tsx +++ b/apps/web/src/features/rankings/rank-columns.test.tsx @@ -1,7 +1,7 @@ import { render, renderHook } from '@testing-library/react'; import { NextIntlClientProvider } from 'next-intl'; import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import en from '../../../messages/en.json'; import { useCompetitorColumns, useRankColumns } from './rank-columns'; @@ -36,119 +36,65 @@ function makeRow(overrides: Record): TrackedKeywordRow { } as unknown as TrackedKeywordRow; } -describe('useRankColumns trend label branches', () => { - it('returns labelFailed when check failed', () => { - const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); - const row = makeRow({ - state: { - ...makeRow({}).state, - current: { rank: null, observed_at: '2026-10-29T10:00:00Z', depth_requested: 20, url: null, state: 'failed' }, - }, - sparkline: { depth_mode: 'daily_depth', points: [] }, - }); - const cell = result.current.trend.cell(row); - const { getByRole } = render(cell); - expect(getByRole('img', { name: 'Rank trend: checks failed' })).toBeInTheDocument(); +/** A row whose metrics hold these monthly volumes, oldest first, ending in September 2026. */ +function withVolumes(volumes: readonly (number | null)[]): TrackedKeywordRow { + const monthly_searches = volumes.map((search_volume, index) => { + const offset = index - (volumes.length - 1); + const at = new Date(Date.UTC(2026, 8 + offset, 1)); + return { year: at.getUTCFullYear(), month: at.getUTCMonth() + 1, search_volume }; }); + return makeRow({ metrics: { monthly_searches } }); +} - it('returns labelEmpty when not checked yet', () => { +describe('useRankColumns trend column', () => { + it('names the volumes the line shows: first, last and the peak', () => { const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); - const row = makeRow({ - state: { - ...makeRow({}).state, - current: { rank: null, observed_at: null, depth_requested: 20, url: null, state: 'not_checked' }, - }, - sparkline: { depth_mode: 'daily_depth', points: [] }, - }); - const cell = result.current.trend.cell(row); - const { getByRole } = render(cell); - expect(getByRole('img', { name: 'Rank trend: no checks yet' })).toBeInTheDocument(); + const { getByRole } = render(result.current.trend.cell(withVolumes([1300, 2400, 1900]))); + expect( + getByRole('img', { name: 'Search volume trend, last 3 months: from 1,300 to 1,900, peak 2,400' }), + ).toBeInTheDocument(); }); - it('returns labelEmpty when sparkline points are empty', () => { + it('reads the first and the last month that has a volume when the ends have none', () => { const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); - const row = makeRow({ - sparkline: { depth_mode: 'daily_depth', points: [] }, - }); - const cell = result.current.trend.cell(row); - const { getByRole } = render(cell); - expect(getByRole('img', { name: 'Rank trend: no checks yet' })).toBeInTheDocument(); + const { getByRole } = render(result.current.trend.cell(withVolumes([null, 500, 700, null]))); + expect( + getByRole('img', { name: 'Search volume trend, last 4 months: from 500 to 700, peak 700' }), + ).toBeInTheDocument(); }); - it('returns labelNone when all checks were beyond depth', () => { + it('says there are no monthly volumes yet when the keyword has no metrics, or none for any month', () => { const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); - const row = makeRow({ - sparkline: { - depth_mode: 'daily_depth', - points: [ - { t: '2026-10-27T10:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - { t: '2026-10-28T10:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - ], - }, - }); - const cell = result.current.trend.cell(row); - const { getByRole } = render(cell); - expect( - getByRole('img', { name: 'Rank trend: not ranked within the depth in the last 2 checks' }), - ).toBeInTheDocument(); + for (const row of [makeRow({}), makeRow({ metrics: null }), withVolumes([]), withVolumes([null, null])]) { + const { getByRole, unmount } = render(result.current.trend.cell(row)); + expect(getByRole('img', { name: 'Search volume trend: no monthly volumes yet' })).toBeInTheDocument(); + unmount(); + } }); - it('uses >depth for latest rank when latest check is beyond depth', () => { + it('does not depend on the rank checks: a keyword whose check failed still shows its volumes', () => { const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); - const row = makeRow({ - sparkline: { - depth_mode: 'daily_depth', - points: [ - { t: '2026-10-27T10:00:00Z', rank: 9, depth: 20, state: 'ranked' }, - { t: '2026-10-28T10:00:00Z', rank: 4, depth: 20, state: 'ranked' }, - { t: '2026-10-29T10:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - ], - }, - }); - const cell = result.current.trend.cell(row); - const { getByRole } = render(cell); + const failed = withVolumes([100, 200]); + const row = { + ...failed, + state: { ...failed.state, current: { ...failed.state.current, state: 'failed' as const, rank: null } }, + } as TrackedKeywordRow; + const { getByRole } = render(result.current.trend.cell(row)); expect( - getByRole('img', { - name: 'Rank trend, last 3 checks: from 9 to >20, best 4, 1 check beyond the depth checked', - }), + getByRole('img', { name: 'Search volume trend, last 2 months: from 100 to 200, peak 200' }), ).toBeInTheDocument(); }); - it('returns standard label when latest check is ranked', () => { + it('lists the last six months in the cell tip, a month without a volume as n/a', () => { const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); - const row = makeRow({ - sparkline: { - depth_mode: 'daily_depth', - points: [ - { t: '2026-10-27T10:00:00Z', rank: 9, depth: 20, state: 'ranked' }, - { t: '2026-10-28T10:00:00Z', rank: 4, depth: 20, state: 'ranked' }, - ], - }, - }); - const cell = result.current.trend.cell(row); - const { getByRole } = render(cell); - expect(getByRole('img', { name: 'Rank trend, last 2 checks: from 9 to 4, best 4' })).toBeInTheDocument(); + const tip = result.current.trend.cellTip?.(withVolumes([1, 2, 3, 4, 5, 1200, null])); + expect(tip?.content).toBe('Monthly search volume'); + expect(tip?.detail).toBe('Apr 2026: 2, May 2026: 3, Jun 2026: 4, Jul 2026: 5, Aug 2026: 1,200, Sep 2026: n/a'); }); - it('formats cell tip dates and ranks using locale formatters without hardcoded formats', () => { - vi.setSystemTime(new Date('2026-10-29T12:00:00Z')); - try { - const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); - const row = makeRow({ - sparkline: { - depth_mode: 'daily_depth', - points: [ - { t: '2026-10-27T10:00:00Z', rank: 3, depth: 20, state: 'ranked' }, - { t: '2026-10-28T10:00:00Z', rank: null, depth: 20, state: 'beyond_depth' }, - ], - }, - }); - const tip = result.current.trend.cellTip?.(row); - expect(tip?.content).toBe('Last checks'); - expect(tip?.detail).toBe('Oct 27: 3, Oct 28: >20'); - } finally { - vi.useRealTimers(); - } + it('has no tip where there are no months', () => { + const { result } = renderHook(() => useRankColumns('UTC'), { wrapper }); + expect(result.current.trend.cellTip?.(makeRow({}))).toBeUndefined(); }); }); diff --git a/apps/web/src/features/rankings/rank-columns.tsx b/apps/web/src/features/rankings/rank-columns.tsx index 3c1565aa..5d167d30 100644 --- a/apps/web/src/features/rankings/rank-columns.tsx +++ b/apps/web/src/features/rankings/rank-columns.tsx @@ -14,8 +14,8 @@ import { sparklineSummary } from '@/lib/charts/sparkline'; import { aioDisplay, formatDetailDateTime, - formatRankCell, - formatTableDate, + formatInteger, + formatMonth, rankDisplay, rowChangeDisplay, urlDisplay, @@ -167,42 +167,33 @@ export function useRankColumns(timeZone: string): RankColumns { priority: 2, width: 96, cell: (row) => { - const points = row.sparkline?.points ?? []; - const label = ((): string => { - if (row.state.current.state === 'failed') return trendT('labelFailed'); - if (row.state.current.state === 'not_checked' || points.length === 0) return trendT('labelEmpty'); - const summary = sparklineSummary(points); - if (summary.first === null) return trendT('labelNone', { checks: summary.checks }); - const latest = points.at(-1); - const lastRankStr = - latest?.state === 'beyond_depth' - ? formatRankCell({ rank: null, depth: latest.depth }) - : String(summary.last ?? summary.first); - const base = trendT('label', { - checks: summary.checks, - first: summary.first, - last: lastRankStr, - best: summary.best ?? summary.first, - }); - return summary.beyond > 0 ? `${base}, ${trendT('labelBeyond', { count: summary.beyond })}` : base; - })(); - return ; + const months = row.metrics?.monthly_searches ?? []; + const values = months.map((month) => month.search_volume); + const summary = sparklineSummary(values); + const label = + summary.first === null || summary.last === null || summary.peak === null + ? trendT('labelEmpty') + : trendT('label', { + months: summary.months, + first: formatInteger(summary.first), + last: formatInteger(summary.last), + peak: formatInteger(summary.peak), + }); + return ; }, cellTip: (row) => { - const points = row.sparkline?.points ?? []; - if (points.length === 0) return undefined; - const last7 = points - .slice(-7) - .map((point) => { - const d = formatTableDate(point.t, timeZone); - const r = formatRankCell({ rank: point.rank, depth: point.depth }); - return trendT('tipPoint', { date: d, rank: r }); - }) + const months = row.metrics?.monthly_searches ?? []; + if (months.length === 0) return undefined; + const recent = months + .slice(-6) + .map((month) => + trendT('tipPoint', { + month: formatMonth(month.year, month.month), + volume: formatInteger(month.search_volume), + }), + ) .join(', '); - return { - content: trendT('tipTitle'), - detail: last7, - }; + return { content: trendT('tipTitle'), detail: recent }; }, }), best: col.rank(RANK_COLUMN_IDS.best, (row) => row.state.best_rank, { diff --git a/apps/web/src/features/rankings/rankings-queries.ts b/apps/web/src/features/rankings/rankings-queries.ts index f1d14127..f08ef811 100644 --- a/apps/web/src/features/rankings/rankings-queries.ts +++ b/apps/web/src/features/rankings/rankings-queries.ts @@ -48,17 +48,9 @@ export const CLIENT_MODE_LIMIT = 10_000; export const MAX_FILTER_VALUES = 100; /** The largest page the API answers. */ const PAGE = 200; -// The pending-metrics poll lays only metrics and state over a waiting row, so a first check's sparkline -// arrives with the next read of the list, which is acceptable for a keyword with one point. +// The Trend column reads the monthly volumes that come with `metrics`, so the rows ask for no rank sparkline. // Competitor ranks are always asked for (small payload per row) to avoid re-reading the list when toggling columns. -const INCLUDE: NonNullable = [ - 'metrics', - 'groups', - 'terms', - 'sparkline', - 'competitor_ranks', - 'ranking_urls', -]; +const INCLUDE: NonNullable = ['metrics', 'groups', 'terms', 'competitor_ranks', 'ranking_urls']; /** The most pages an export reads: 50 of 200 are the 10 000 keywords the table itself holds. */ const EXPORT_PAGES_MAX = 50; diff --git a/apps/web/src/features/rankings/rankings.test.tsx b/apps/web/src/features/rankings/rankings.test.tsx index 4a1216cd..66825721 100644 --- a/apps/web/src/features/rankings/rankings.test.tsx +++ b/apps/web/src/features/rankings/rankings.test.tsx @@ -703,7 +703,7 @@ describe('Rankings rank columns', () => { expect(within(cell).queryByText(/Cited/)).toBeNull(); }); - it('shows the rank trend sparkline for checked and pending keywords, and requests include=sparkline', async () => { + it('shows the search volume trend of each keyword from its metrics, and asks for no rank sparkline', async () => { const asked: string[] = []; const listener = ({ request }: { request: Request }): void => { if (request.url.includes('/tracked-keywords')) { @@ -716,7 +716,8 @@ describe('Rankings rank columns', () => { renderWithProviders(as('viewer')); const grid = await loadedGrid(); expect(within(grid).getByRole('columnheader', { name: /^Trend/ })).toBeInTheDocument(); - await waitFor(() => expect(asked).toContain('sparkline')); + await waitFor(() => expect(asked).toContain('metrics')); + expect(asked).not.toContain('sparkline'); const checked = fixtureStates().find(({ state }) => state.current.state === 'ranked'); if (checked === undefined) throw new Error('No checked keyword'); @@ -729,10 +730,11 @@ describe('Rankings rank columns', () => { if (checkedCell === undefined) throw new Error('No Trend cell on checked row'); expect( within(checkedCell).getByRole('img', { - name: 'Rank trend, last 30 checks: from 19 to 12, best 8, 1 check beyond the depth checked', + name: /^Search volume trend, last 12 months: from [\d,]+ to [\d,]+, peak [\d,]+$/, }), ).toBeInTheDocument(); + // A keyword that is still waiting for its first rank check has its own volumes all the same const pendingRow = within(grid) .getAllByRole('rowheader') .find((candidate) => named(candidate, 'generative engine optimization')) @@ -740,7 +742,7 @@ describe('Rankings rank columns', () => { if (!pendingRow) throw new Error('No row for pending keyword'); const pendingCell = pendingRow.cells[columnIndex(grid, /^Trend$/)]; if (pendingCell === undefined) throw new Error('No Trend cell on pending row'); - expect(within(pendingCell).getByRole('img', { name: 'Rank trend: no checks yet' })).toBeInTheDocument(); + expect(within(pendingCell).getByRole('img', { name: /^Search volume trend/ })).toBeInTheDocument(); } finally { server.events.removeListener('request:start', listener); } diff --git a/apps/web/src/lib/api/schema.ts b/apps/web/src/lib/api/schema.ts index 2a989673..b03522fe 100644 --- a/apps/web/src/lib/api/schema.ts +++ b/apps/web/src/lib/api/schema.ts @@ -3682,6 +3682,11 @@ export interface components { language_code: string; /** Location Id */ location_id: number; + /** + * Monthly Searches + * @description The provider's monthly volumes behind the trend, oldest first; empty when the provider sent none. + */ + monthly_searches: components["schemas"]["MonthlySearches"][]; search_volume: components["schemas"]["MetricValue"]; /** * Source diff --git a/apps/web/src/lib/charts/sparkline.test.ts b/apps/web/src/lib/charts/sparkline.test.ts index 5b255e8b..03e62632 100644 --- a/apps/web/src/lib/charts/sparkline.test.ts +++ b/apps/web/src/lib/charts/sparkline.test.ts @@ -2,61 +2,61 @@ import { describe, expect, it } from 'vitest'; import { SPARK_HEIGHT, SPARK_WIDTH, sparklineGeometry, sparklineSummary } from './sparkline'; -const ranked = (rank: number) => ({ rank, state: 'ranked' as const }); -const beyond = { rank: null, state: 'beyond_depth' as const }; +describe('sparklineGeometry', () => { + it('puts the lowest month on the bottom edge and the highest on the top edge, higher volume higher up', () => { + const { segments } = sparklineGeometry([100, 400, 250]); + const [run] = segments; + expect(run).toHaveLength(3); + expect(run?.[1]?.y).toBeLessThan(run?.[2]?.y ?? 0); + expect(run?.[2]?.y).toBeLessThan(run?.[0]?.y ?? 0); + expect(run?.[1]?.y).toBeGreaterThan(0); + expect(run?.[0]?.y).toBeLessThan(SPARK_HEIGHT); + }); -describe('sparkline geometry', () => { - it('puts rank 1 at the top and rank 20 at the bottom, inside the padding', () => { - const { segments } = sparklineGeometry([ranked(1), ranked(20)]); - const [first, second] = segments[0] ?? []; - expect(first).toEqual({ x: 2.5, y: 2.5 }); - expect(second).toEqual({ x: SPARK_WIDTH - 2.5, y: SPARK_HEIGHT - 2.5 }); + it('scales each series to its own range, so a keyword of 50 searches and one of 50,000 draw alike', () => { + expect(sparklineGeometry([50, 100, 75])).toEqual(sparklineGeometry([50_000, 100_000, 75_000])); }); - it('keeps a deeper rank on the bottom edge', () => { - const { segments } = sparklineGeometry([ranked(1), ranked(45)]); - expect(segments[0]?.[1]?.y).toBe(SPARK_HEIGHT - 2.5); + it('draws a series that never moves in the middle of the box', () => { + const { segments } = sparklineGeometry([300, 300, 300]); + expect(segments[0]?.every((point) => point.y === SPARK_HEIGHT / 2)).toBe(true); }); - it('breaks the line at a check beyond the depth', () => { - const { segments } = sparklineGeometry([ranked(3), ranked(4), beyond, ranked(5)]); - expect(segments.map((segment) => segment.length)).toEqual([2, 1]); + it('spreads the months over the width, oldest at the left', () => { + const [run] = sparklineGeometry([1, 2, 3, 4]).segments; + const xs = run?.map((point) => point.x) ?? []; + expect(xs).toEqual([...xs].sort((a, b) => a - b)); + expect(xs[0]).toBeGreaterThan(0); + expect(xs.at(-1)).toBeLessThan(SPARK_WIDTH); }); - it('marks the last point only when the last check ranked', () => { - expect(sparklineGeometry([ranked(3), ranked(4)]).last).not.toBeNull(); - expect(sparklineGeometry([ranked(3), beyond]).last).toBeNull(); + it('breaks the line at a month without a volume and keeps the runs on one scale', () => { + const { segments } = sparklineGeometry([10, 20, null, 30, 40]); + expect(segments.map((run) => run.length)).toEqual([2, 2]); }); - it('centres a single point', () => { - expect(sparklineGeometry([ranked(10)]).segments).toEqual([[{ x: SPARK_WIDTH / 2, y: expect.any(Number) }]]); + it('marks the last month only when it has a volume', () => { + expect(sparklineGeometry([3, 4]).last).not.toBeNull(); + expect(sparklineGeometry([3, null]).last).toBeNull(); }); - it('has nothing to draw without points or when all points are beyond depth', () => { - expect(sparklineGeometry([])).toEqual({ segments: [], last: null }); - expect(sparklineGeometry([beyond, beyond])).toEqual({ segments: [], last: null }); + it('draws one month as a dot in the middle of the width', () => { + expect(sparklineGeometry([10]).segments).toEqual([[{ x: SPARK_WIDTH / 2, y: SPARK_HEIGHT / 2 }]]); }); - it('creates a single one-point segment for [beyond, ranked, beyond]', () => { - const geo = sparklineGeometry([beyond, ranked(8), beyond]); - expect(geo.segments).toHaveLength(1); - expect(geo.segments[0]).toHaveLength(1); - expect(geo.last).toBeNull(); + it('draws nothing without a volume', () => { + expect(sparklineGeometry([])).toEqual({ segments: [], last: null }); + expect(sparklineGeometry([null, null])).toEqual({ segments: [], last: null }); }); }); -describe('sparkline summary', () => { - it('says where the rank started and ended, the best, and how many checks were beyond the depth', () => { - expect(sparklineSummary([ranked(9), beyond, ranked(3), ranked(4)])).toEqual({ - checks: 4, - first: 9, - last: 4, - best: 3, - beyond: 1, - }); +describe('sparklineSummary', () => { + it('reads the first and last month with a volume, and the peak', () => { + expect(sparklineSummary([null, 900, 2400, 1300, null])).toEqual({ months: 5, first: 900, last: 1300, peak: 2400 }); }); - it('has no ranks when every check was beyond the depth', () => { - expect(sparklineSummary([beyond, beyond])).toEqual({ checks: 2, first: null, last: null, best: null, beyond: 2 }); + it('has nothing to read where no month has a volume', () => { + expect(sparklineSummary([null, null])).toEqual({ months: 2, first: null, last: null, peak: null }); + expect(sparklineSummary([])).toEqual({ months: 0, first: null, last: null, peak: null }); }); }); diff --git a/apps/web/src/lib/charts/sparkline.ts b/apps/web/src/lib/charts/sparkline.ts index 94f79110..aa4762e6 100644 --- a/apps/web/src/lib/charts/sparkline.ts +++ b/apps/web/src/lib/charts/sparkline.ts @@ -1,16 +1,13 @@ -import type { SparklinePoint } from '@/lib/api/types'; - /** - * Where a rank sparkline's line and last point fall in its box (15-data-visualization.md section 9): ranks - * inverted, better up, on a scale fixed per table (1 to 20) so rows compare; a deeper rank sits on the bottom - * edge; a check beyond the depth breaks the line. The canvas only draws what this returns. + * Where a search volume sparkline's line and last point fall in its box (15-data-visualization.md section 9): + * the months oldest first, higher volume higher up, on a scale of the keyword's own lowest and highest month + * (volumes differ by orders of magnitude from one keyword to the next, so rows show the shape, and the cell's + * tooltip the numbers); a month with no volume breaks the line. The canvas only draws what this returns. */ export const SPARK_WIDTH = 64; export const SPARK_HEIGHT = 20; const PAD = 2.5; -/** The rank at the bottom edge of every sparkline in a table. */ -export const SPARK_RANK_SCALE = 20; export interface SparkPoint { x: number; @@ -18,29 +15,34 @@ export interface SparkPoint { } export interface SparklineGeometry { - /** Runs of ranked checks; a check beyond the depth ends a run. */ + /** Runs of months with a volume; a month without one ends a run. */ segments: SparkPoint[][]; - /** The latest check, when it ranked. */ + /** The latest month, when it has a volume. */ last: SparkPoint | null; } -type Point = Pick; +/** A month's volume, or `null` where the provider sent none. */ +export type SparkValue = number | null; export function sparklineGeometry( - points: readonly Point[], + values: readonly SparkValue[], width: number = SPARK_WIDTH, height: number = SPARK_HEIGHT, ): SparklineGeometry { + const known = values.filter((value): value is number => value !== null); + const low = Math.min(...known); + const range = Math.max(...known) - low; const segments: SparkPoint[][] = []; - const step = points.length > 1 ? (width - PAD * 2) / (points.length - 1) : 0; + const step = values.length > 1 ? (width - PAD * 2) / (values.length - 1) : 0; let run: SparkPoint[] | null = null; - for (const [index, point] of points.entries()) { - if (point.state !== 'ranked' || point.rank === null) { + for (const [index, value] of values.entries()) { + if (value === null) { run = null; continue; } - const share = (Math.min(Math.max(point.rank, 1), SPARK_RANK_SCALE) - 1) / (SPARK_RANK_SCALE - 1); - const at = { x: points.length > 1 ? PAD + index * step : width / 2, y: PAD + share * (height - PAD * 2) }; + // A series that never moves sits in the middle of the box + const share = range === 0 ? 0.5 : (value - low) / range; + const at = { x: values.length > 1 ? PAD + index * step : width / 2, y: PAD + (1 - share) * (height - PAD * 2) }; if (run === null) { run = [at]; segments.push(run); @@ -48,29 +50,26 @@ export function sparklineGeometry( run.push(at); } } - const latest = points.at(-1); - const last = latest?.state === 'ranked' && latest.rank !== null ? (segments.at(-1)?.at(-1) ?? null) : null; + const last = values.at(-1) === null || values.length === 0 ? null : (segments.at(-1)?.at(-1) ?? null); return { segments, last }; } export interface SparklineSummary { - checks: number; - /** The first and last ranked checks' ranks, and the best. */ + /** The months in the series, those without a volume included. */ + months: number; + /** The volumes of the first and the last month that has one, and the highest. */ first: number | null; last: number | null; - best: number | null; - /** Checks beyond the depth checked. */ - beyond: number; + peak: number | null; } -/** The numbers of a sparkline's accessible name (20-accessibility.md: "from 9 to 4, best 3, 2 checks not in top 20"). */ -export function sparklineSummary(points: readonly Point[]): SparklineSummary { - const ranks = points.flatMap((point) => (point.state === 'ranked' && point.rank !== null ? [point.rank] : [])); +/** The numbers of a sparkline's accessible name (20-accessibility.md: "from 1,300 to 1,900, peak 2,400"). */ +export function sparklineSummary(values: readonly SparkValue[]): SparklineSummary { + const known = values.filter((value): value is number => value !== null); return { - checks: points.length, - first: ranks[0] ?? null, - last: ranks.at(-1) ?? null, - best: ranks.length === 0 ? null : Math.min(...ranks), - beyond: points.length - ranks.length, + months: values.length, + first: known[0] ?? null, + last: known.at(-1) ?? null, + peak: known.length === 0 ? null : Math.max(...known), }; } diff --git a/apps/web/src/mocks/keyword-metrics.ts b/apps/web/src/mocks/keyword-metrics.ts index fe0c08e4..d4152061 100644 --- a/apps/web/src/mocks/keyword-metrics.ts +++ b/apps/web/src/mocks/keyword-metrics.ts @@ -155,6 +155,7 @@ export function keywordMetrics(reading: LabsReading): KeywordMetrics { intent_main: reading.intent, trend_pct_per_month: trendPctPerMonth(months), is_seasonal: seasonalityOf(months).seasonal, + monthly_searches: months, }; } diff --git a/apps/web/src/test/api-shapes.ts b/apps/web/src/test/api-shapes.ts index ebd0e89f..5a9e6950 100644 --- a/apps/web/src/test/api-shapes.ts +++ b/apps/web/src/test/api-shapes.ts @@ -303,6 +303,7 @@ export const KEYWORD_METRICS: z.ZodType = z.object({ intent_main: z.string().nullable(), trend_pct_per_month: z.number().nullable(), is_seasonal: z.boolean().nullable(), + monthly_searches: z.array(z.object({ year: z.number(), month: z.number(), search_volume: z.number().nullable() })), }); export const KEYWORD_DETAIL: z.ZodType = z.object({ diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index b58a359e..30cf79bb 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4803,6 +4803,14 @@ "title": "Location Id", "type": "integer" }, + "monthly_searches": { + "description": "The provider's monthly volumes behind the trend, oldest first; empty when the provider sent none.", + "items": { + "$ref": "#/components/schemas/MonthlySearches" + }, + "title": "Monthly Searches", + "type": "array" + }, "search_volume": { "$ref": "#/components/schemas/MetricValue" }, @@ -4840,7 +4848,8 @@ "keyword_difficulty", "intent_main", "trend_pct_per_month", - "is_seasonal" + "is_seasonal", + "monthly_searches" ], "title": "KeywordMetrics", "type": "object" diff --git a/backend/src/idraft/api/keywords/metric_values.py b/backend/src/idraft/api/keywords/metric_values.py index 4bd0e3dc..ddde6af5 100644 --- a/backend/src/idraft/api/keywords/metric_values.py +++ b/backend/src/idraft/api/keywords/metric_values.py @@ -52,6 +52,13 @@ def months_of(monthly: Sequence[Mapping[str, object]]) -> list[Month]: return sorted(found, key=lambda month: (month.year, month.month)) +def monthly_view(months: Sequence[Month]) -> list[MonthlySearches]: + return [ + MonthlySearches(year=month.year, month=month.month, search_volume=month.search_volume) + for month in months + ] + + def _month_end(day: date) -> date: return day.replace(day=calendar.monthrange(day.year, day.month)[1]) @@ -129,6 +136,7 @@ def keyword_metrics(record: MetricsRecord) -> KeywordMetrics: intent_main=record.intent_main, trend_pct_per_month=trend_pct_per_month(months), is_seasonal=is_seasonal, + monthly_searches=monthly_view(months), ) @@ -153,12 +161,7 @@ def history( fetched_at=snapshot.fetched_at, data_month=snapshot.data_month, search_volume=snapshot.search_volume, - monthly_searches=[ - MonthlySearches( - year=month.year, month=month.month, search_volume=month.search_volume - ) - for month in months_of(snapshot.monthly_searches) - ], + monthly_searches=monthly_view(months_of(snapshot.monthly_searches)), cpc_usd=snapshot.cpc_usd, keyword_difficulty=snapshot.keyword_difficulty, is_material_change=snapshot.is_material_change, diff --git a/backend/src/idraft/api/schemas/keywords.py b/backend/src/idraft/api/schemas/keywords.py index bd92a8db..f9d21b69 100644 --- a/backend/src/idraft/api/schemas/keywords.py +++ b/backend/src/idraft/api/schemas/keywords.py @@ -404,6 +404,12 @@ class AioCitations(BaseModel): CompetitionLevel = Literal["LOW", "MEDIUM", "HIGH"] +class MonthlySearches(BaseModel): + year: int + month: int + search_volume: int | None + + class KeywordMetrics(BaseModel): source: MetricsSource location_id: int @@ -418,6 +424,10 @@ class KeywordMetrics(BaseModel): intent_main: str | None trend_pct_per_month: float | None is_seasonal: bool | None + monthly_searches: list[MonthlySearches] = Field( + description="The provider's monthly volumes behind the trend, oldest first; " + "empty when the provider sent none." + ) class GroupRef(BaseModel): @@ -730,12 +740,6 @@ class KeywordDetailResponse(BaseModel): data: KeywordDetail -class MonthlySearches(BaseModel): - year: int - month: int - search_volume: int | None - - class MetricsSnapshot(BaseModel): snapshot_id: str fetched_at: datetime diff --git a/backend/tests/api/keywords/test_tracked_keywords.py b/backend/tests/api/keywords/test_tracked_keywords.py index 1946d1a6..bd0e44a5 100644 --- a/backend/tests/api/keywords/test_tracked_keywords.py +++ b/backend/tests/api/keywords/test_tracked_keywords.py @@ -584,6 +584,10 @@ async def test_the_detail_carries_current_metrics( "2026-08-01", ) assert (metrics["trend_pct_per_month"], metrics["is_seasonal"]) == (0.0, False) + # The twelve months behind the trend, oldest first, for the table's Trend column + assert len(metrics["monthly_searches"]) == 12 + assert metrics["monthly_searches"][0] == {"year": 2025, "month": 9, "search_volume": 100} + assert metrics["monthly_searches"][-1] == {"year": 2026, "month": 8, "search_volume": 100} async def test_a_change_to_depth_or_target_url_makes_a_new_config_version( diff --git a/docs/19-api-specification.md b/docs/19-api-specification.md index 01987d21..a1f99fb2 100644 --- a/docs/19-api-specification.md +++ b/docs/19-api-specification.md @@ -864,6 +864,7 @@ interface KeywordMetrics { // from seo.keyword_metrics_cur cpc_usd: Usd | null; competition: number | null; competition_level: 'LOW' | 'MEDIUM' | 'HIGH' | null; keyword_difficulty: number | null; // provider metric, doc 09 section 8 intent_main: string | null; trend_pct_per_month: number | null; is_seasonal: boolean | null; + monthly_searches: Array<{ year: number; month: number; search_volume: number | null }>; // the provider's months behind the trend, oldest first; [] when it sent none. The table's Trend column draws these } interface KeywordMetricsHistory { snapshots: Array<{ snapshot_id: BigId; fetched_at: Iso; data_month: Day | null; search_volume: number | null; @@ -1777,7 +1778,8 @@ Ids are shortened for readability where marked `...`; everything else is a liter "filters": { "location_id": 2840, "language_code": "en" }, "is_estimate": true, "method": "Provider-reported average monthly search volume (DataForSEO Labs), annual average", "method_version": 1 }, "cpc_usd": "14.2300", "competition": 0.67, "competition_level": "HIGH", "keyword_difficulty": 58, - "intent_main": "commercial", "trend_pct_per_month": 1.8, "is_seasonal": false + "intent_main": "commercial", "trend_pct_per_month": 1.8, "is_seasonal": false, + "monthly_searches": [ { "year": 2025, "month": 9, "search_volume": 1700 }, "... 10 more ...", { "year": 2026, "month": 8, "search_volume": 1900 } ] }, "competitors": [ { "entity": { "entity_id": "0192c1...01", "canonical_name": "HubSpot", "entity_type": "brand", "role": "competitor", "color_index": 2 }, diff --git a/docs/20-frontend-specification.md b/docs/20-frontend-specification.md index 18c5d9db..441464cd 100644 --- a/docs/20-frontend-specification.md +++ b/docs/20-frontend-specification.md @@ -582,7 +582,7 @@ Columns (default set in bold, rest in the column chooser): | **SERP features** | Icons: outline = present, filled = owned; accessible names list both | | **AIO** | absent, present (not cited), cited #n | | **Competitor positions** | One column per selected competitor (first 2 by default), rank or depth chip | -| **Trend** | 30-check sparkline with the depth band as a grey strip | +| **Trend** | Sparkline of the keyword's monthly search volume, last 12 months (superseded: this was the 30-check rank sparkline with the depth band as a grey strip; as built, the Trend column reads `metrics.monthly_searches`, see the note on the four new columns below) | | Absolute rank, pixel displacement proxy | Secondary | | Volatility, URL stability | With "10 ranked checks needed" insufficient state | | GSC clicks, impressions, avg position | Labeled GSC, low-data rule | @@ -618,6 +618,8 @@ States: stale rows show the badge and last successful check; keywords added toda > > Four new columns are added: SERP features (`features`, `SERPFeatureList`), AI Overview (`aiOverview`, `AioStatusView`), Trend (`sparkline`, `SparklineCell` 64 x 20 px in both compact and comfortable densities), and Competitor positions (`competitor`, `CompetitorRankView`, one column per project competitor sorted by `color_index` ascending then name, default visible for the first competitor). None of the four column types sorts. > +> Trend shows search volume, not rank (2026-11-05): the last 12 months of `metrics.monthly_searches` (`include=metrics`, which the table asks for already; the rows no longer ask for `include=sparkline`, which the API still answers). Higher volume is higher up, on a scale of the row's own lowest and highest month, so a keyword of 50 searches and one of 50,000 draw alike and the shape is what the row shows; a month without a volume breaks the line, and the last month gets the dot. The cell's accessible name is "Search volume trend, last 12 months: from 1,300 to 1,900, peak 2,400", the cell tip lists the last 6 months ("Mar 2026: 1,900"), and a keyword with no monthly volumes yet shows the dashed line named "Search volume trend: no monthly volumes yet". The column header stays "Trend", with no tooltip of its own: the cell tip says what the line is. +> > Target URL mismatch marker (`TargetMismatchMarker`) renders on the Keyword cell when `row.state.target_url_match === false`. Two new filter chips: `featown` (SERP feature you own) and `tmatch` (Target URL match: Matches target URL, Mismatches target URL). > > Summary metric strip (`RankStrip`) displays Tracked, Top 3, Top 10, and Top 20 counts via five one-row reads; deltas, Average rank and Visibility wait for Task 9 (blocked on backend API `GET {P}/seo/rank-summary`). Check now (`useCheckNow`) supports checking up to 100 keywords from the bulk bar or an individual keyword from the inspector header (`frequentAction`), following run progress under the page title. The mock check now records a run and counts toward manual run limits but does not alter ranks. diff --git a/docs/design-system/15-data-visualization.md b/docs/design-system/15-data-visualization.md index 96d18a95..ff3bafba 100644 --- a/docs/design-system/15-data-visualization.md +++ b/docs/design-system/15-data-visualization.md @@ -209,6 +209,7 @@ The matrix compares organic ranking strength with AI visibility for keywords (or | Area | None | | Last point | 3 px dot in the stroke color | | Baseline | Optional 1 px dashed `chart-grid` at zero (rates, counts) | +| Search volume | The Keywords table's Trend: the last 12 months, oldest at the left, higher is up, on a scale of the row's own lowest and highest month (volumes differ by orders of magnitude from one keyword to the next, so the row shows the shape and the cell tip the numbers); a month without a volume breaks the line; a series that never moves sits mid-height | | Ranks | Inverted (better is up); scale fixed per table (1 to 20 by default) so rows are comparable; beyond-depth points break the line | | Rates | Drawn in a window of at least 20 points around the row's own values, so a move of a point or two stays flat and a real move shows. A week without a usable rate breaks the line, and a series that ends in a gap has no last point | | Direction color | The line is never red or green. The adjacent delta cell carries color when warranted | diff --git a/docs/design-system/19-responsive-behavior.md b/docs/design-system/19-responsive-behavior.md index bf76b063..6255e06f 100644 --- a/docs/design-system/19-responsive-behavior.md +++ b/docs/design-system/19-responsive-behavior.md @@ -218,7 +218,7 @@ Content width budget: 1,030 px at 1280, 1,190 px at 1440, 1,430 px at 1680 (view | Ranking URL | 2 | 200 px | `URLCell`, middle truncation | | Volume | 2 | 80 px | Estimate badge in the header | | AI Overview | 2 | 104 px | Absent, present, cited #n | -| Trend | 2 | 96 px | 30-check sparkline | +| Trend | 2 | 96 px | Sparkline of 12 months of search volume | | Intent | 3 | 96 px | Neutral chip | | SERP Features | 3 | 120 px | Icons with accessible names | | Competitor | 4 | 120 px per competitor | First selected competitor; further competitors are chooser-only and take priority 4 | diff --git a/docs/design-system/20-accessibility.md b/docs/design-system/20-accessibility.md index 66f19b01..e7f6226f 100644 --- a/docs/design-system/20-accessibility.md +++ b/docs/design-system/20-accessibility.md @@ -453,7 +453,7 @@ Formatters in `@/lib/format` ([22-content-formatting.md](22-content-formatting.m | Not applicable | `No AIO` in cells; `No AI Overview in window` in tiles and the inspector | "Not applicable, no AI Overview in window" | | AI Overview cell | `Cited #2` | "AI Overview present, cited at position 2" | | SERP features | Icons | List named "SERP features": "Featured snippet, owned. People also ask. Video" | -| Sparkline | Line | `role="img"`, "Rank trend, last 30 checks: from 9 to 4, best 3, 2 checks not in top 20" | +| Sparkline | Line | `role="img"`, "Search volume trend, last 12 months: from 1,300 to 1,900, peak 2,400" | | Relative time | `2h ago` | "2 hours ago" with the absolute time in the tooltip | | Minus sign | `−3` (U+2212) | Read as "minus 3" by VoiceOver and NVDA; no extra handling | diff --git a/docs/design-system/23-application-patterns.md b/docs/design-system/23-application-patterns.md index 5c934c30..2263bae4 100644 --- a/docs/design-system/23-application-patterns.md +++ b/docs/design-system/23-application-patterns.md @@ -33,7 +33,7 @@ Rules: | Object | L1 Scan (row) | L2 Hover (tooltip) | L3 Inspect (inspector) | L4 Study (full page) | |---|---|---|---|---| -| **Keyword** (tracked keyword) | Keyword text, rank, rank change, ranking URL path, volume, intent, SERP feature icons, AI Overview status, top competitor rank, 30-check sparkline, updated | Location, device, depth and check time; previous rank and normal movement (up to 2 positions); full ranking URL; volume source and month; last 7 ranks; AI Overview position and source count; feature names | Rank strip (current, best, average with `n_ranked of n_checks`, volatility); 30-day rank chart with depth band; target vs ranking URL; latest SERP features; competitor ranks (top 5); latest AI Overview; related prompts (top 3 with per-engine mention rate); last 10 events; actions | Keyword Detail: full history with deep checks, SERP snapshots and compare, feature strip, AI Overview history, related prompts with intervals, GSC panel, demand history, diagnostics | +| **Keyword** (tracked keyword) | Keyword text, rank, rank change, ranking URL path, volume, intent, SERP feature icons, AI Overview status, top competitor rank, 12-month search volume sparkline, updated | Location, device, depth and check time; previous rank and normal movement (up to 2 positions); full ranking URL; volume source and month; last 7 ranks; AI Overview position and source count; feature names | Rank strip (current, best, average with `n_ranked of n_checks`, volatility); 30-day rank chart with depth band; target vs ranking URL; latest SERP features; competitor ranks (top 5); latest AI Overview; related prompts (top 3 with per-engine mention rate); last 10 events; actions | Keyword Detail: full history with deep checks, SERP snapshots and compare, feature strip, AI Overview history, related prompts with intervals, GSC panel, demand history, diagnostics | | **Prompt** | Prompt text (one line), topic, per-engine mention rate, mention rate (equal-weight, labeled), citation rate, top competitor, trend, updated | Full prompt text; `k of n` and interval per engine; prompt type, panel, version; last observed per engine | Per-engine rate table (mention, citation, recommendation with `k of n`); latest answer (`ResponsePreview`); observation history (last 12); entities mentioned with counts; top 5 cited domains; competitors; related keywords with rank; confidence and sample | Prompt Detail: trends with CI bands, all answers with evidence and compare, citations, competitor gap (A14), recommendation and sentiment (A5, A6, A13), volatility, fan-out, versions, statistics | | **Observation** (one answer) | Engine, date, replicate, validity status, mentioned, cited, recommended (icon plus word), structured rank, citation count | Invalid reason, reported model, run, collection time | Evidence viewer (section 5): answer with mention highlights, entity toggles, `CitationList`, facts footer | Prompt Detail, Answers tab with `obs=`: compare with another observation, raw payload (`raw:read`) | | **Domain** (cited registrable domain) | Domain with favicon, role, category, citation units (C2), source share (C1), change, observations, prompts, per-engine share, new or lost badge, trend | Full host, category source (default or override), first and last seen, `min_support`, per-engine counts | Share trend (180 px, per engine); top 10 cited pages; top 10 prompts citing it; entities co-mentioned when it is cited; SEO SOV of the domain on project keywords; category override action | Citations, Pages tab filtered to the domain (`domain=`); publisher opportunity row when present. No dedicated domain page in V1 | diff --git a/docs/design-system/24-page-templates.md b/docs/design-system/24-page-templates.md index 1a90e4e3..41e967f9 100644 --- a/docs/design-system/24-page-templates.md +++ b/docs/design-system/24-page-templates.md @@ -469,7 +469,7 @@ In the drawing, `[]` stands for a SERP feature icon slot, intent badges are shor | 7 | SERP features | `features` | 120 / 112 | 3 | `SERPFeatureList`, owned in `primary-text` | | 8 | AI Overview | `aiOverview` | 104 / 96 | 2 | `AIOverviewStatus` | | 9 | Competitor | `competitor` | 120 / 120 | 4 | First selected competitor's rank; more competitors via the column chooser | -| 10 | Trend | `sparkline` | 96 / 88 | 2 | 30 checks, inverted, depth breaks | +| 10 | Trend | `sparkline` | 96 / 88 | 2 | Monthly search volume, last 12 months, higher is up, on the row's own scale; a month without a volume breaks the line | | 11 | Updated | `date` | 104 / 104 | 4 | Stale badge after threshold | | 12 | (actions) | `actions` | 40 / 40 | always | Row "More" menu | diff --git a/docs/design-system/25-component-inventory.md b/docs/design-system/25-component-inventory.md index 7d9a7fbd..0096e7b3 100644 --- a/docs/design-system/25-component-inventory.md +++ b/docs/design-system/25-component-inventory.md @@ -3850,13 +3850,13 @@ interface RankDeltaProps { ### 14.2 RankTrend `app/sparkline/rank-trend.tsx` · V1 · [15 §9](15-data-visualization.md), [24 §9.3](24-page-templates.md) -Purpose: `SparklineCell` preset for ranks: 30 checks, inverted, fixed scale 1 to 20 per table, beyond-depth checks break the line. +Purpose: `SparklineCell` preset for ranks: 30 checks, inverted, fixed scale 1 to 20 per table, beyond-depth checks break the line. Not used by the Keywords table any more: its Trend column draws 12 months of search volume ([15 §9](15-data-visualization.md), row "Search volume"), and the rank history is shown in the inspector and Keyword Detail. ```ts interface RankTrendProps { checks: ReadonlyArray<{ date: BusinessDate; obs: RankObservation }>; scaleMax: 20 | 100; density: Density } ``` -- **States:** data, gaps, empty. **A11y:** summary "Rank trend, last 30 checks: from 9 to 4, best 3, 2 checks not in top 20". **Use:** Rankings Trend column. **Not for:** rate trends (`SparklineCell` with `invert: false`). +- **States:** data, gaps, empty. **A11y:** summary "Rank trend, last 30 checks: from 9 to 4, best 3, 2 checks not in top 20". **Use:** a rank history in a small space. **Not for:** the Rankings Trend column (search volume), rate trends (`SparklineCell` with `invert: false`). ### 14.3 RankDistribution `app/seo/rank-distribution.tsx` · V1 · [24 §9.2](24-page-templates.md), [15 §5](15-data-visualization.md) From d1a7682a532f302556042b61ae735d8372f9e6d4 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Thu, 5 Nov 2026 12:24:54 +0530 Subject: [PATCH 2/2] Satisfy shellcheck SC2015 in three infra scripts Rewrite the A && B || die checks as explicit if statements with the same behaviour. The infra lint job fails on these info notes. --- infra/ci/resolve-release.sh | 4 +++- infra/deploy.sh | 5 +++-- infra/test/parallel-stack.sh | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/infra/ci/resolve-release.sh b/infra/ci/resolve-release.sh index 91e73622..7d254377 100755 --- a/infra/ci/resolve-release.sh +++ b/infra/ci/resolve-release.sh @@ -187,7 +187,9 @@ case "$MODE" in api="${INPUT_API:-}" worker="${INPUT_WORKER:-}" web="${INPUT_WEB:-}" - [ -n "$api" ] && [ -n "$worker" ] && [ -n "$web" ] || die "a manual deploy needs all three image references" + if [ -z "$api" ] || [ -z "$worker" ] || [ -z "$web" ]; then + die "a manual deploy needs all three image references" + fi # Separate assignments: a failure inside an argument's command substitution would not stop the script. api_commit="$(image_commit "$api")" worker_commit="$(image_commit "$worker")" diff --git a/infra/deploy.sh b/infra/deploy.sh index 721829fb..2ef99a2f 100755 --- a/infra/deploy.sh +++ b/infra/deploy.sh @@ -177,8 +177,9 @@ read_release() { worker_image="$(jq -r '.worker // empty' "$file")" || die "Not valid JSON: $file" web_image="$(jq -r '.web // empty' "$file")" || die "Not valid JSON: $file" release_sha="$(jq -r '.sha // empty' "$file")" || die "Not valid JSON: $file" - [ -n "$api_image" ] && [ -n "$worker_image" ] && [ -n "$web_image" ] \ - || die "Release file must define .api, .worker and .web: $file" + if [ -z "$api_image" ] || [ -z "$worker_image" ] || [ -z "$web_image" ]; then + die "Release file must define .api, .worker and .web: $file" + fi validate_image api "$api_image" validate_image worker "$worker_image" validate_image web "$web_image" diff --git a/infra/test/parallel-stack.sh b/infra/test/parallel-stack.sh index b304415f..6023f2d3 100755 --- a/infra/test/parallel-stack.sh +++ b/infra/test/parallel-stack.sh @@ -42,7 +42,9 @@ done ids_a="$(docker ps -q --filter label=com.docker.compose.project=idraft-par-a | sort)" ids_b="$(docker ps -q --filter label=com.docker.compose.project=idraft-par-b | sort)" -[ -n "$ids_a" ] && [ -n "$ids_b" ] || fail "a project has no containers" +if [ -z "$ids_a" ] || [ -z "$ids_b" ]; then + fail "a project has no containers" +fi [ -z "$(comm -12 <(echo "$ids_a") <(echo "$ids_b"))" ] || fail "the projects share a container" echo "project a: $(echo "$ids_a" | wc -l | tr -d ' ') containers, project b: $(echo "$ids_b" | wc -l | tr -d ' ') containers" docker ps --filter label=com.docker.compose.project --format '{{.Names}}\t{{.Ports}}' | grep -E 'idraft-par' | sort