From 752561b0acb2fcb4478880ebea22ce7aa2078b14 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 1 Jul 2026 03:52:12 +0800 Subject: [PATCH] feat: add refresh metrics action to Saved Keywords page (#51) * feat: add refresh metrics action to Saved Keywords page Adds an Actions dropdown to the Saved Keywords header with an "Update keyword stats" option. Fetches fresh volume, CPC, competition, difficulty, and intent from DataForSEO for all saved keywords in the project, grouped by location/language. Mirrors the existing refresh pattern from Rank Tracking. Closes #49 * fix(keywords): preserve full metric shape on saved-keyword refresh Align refreshSavedKeywordMetrics with the research/save persistence shape so a refresh never degrades stored data: - Persist real monthly_searches trend instead of writing "[]" - Derive Google Ads competition from competition_index/100 instead of null - Normalize intent via normalizeIntent (Labs) / "unknown" (Ads) to match mapKeywordDataItems / mapAdsKeywordItems Also extract the per-batch fetch+map into fetchBatchMetrics to resolve the oxlint max-depth violation, and apply prettier formatting so ci:check passes. --------- Co-authored-by: Ben Senescu --- .../saved-keywords/SavedKeywordsHeader.tsx | 124 ++++++++++----- src/routes/_project/p/$projectId/saved.tsx | 18 +++ .../services/KeywordResearchService.ts | 2 + .../keywords/services/research/index.ts | 1 + .../services/research/refresh-metrics.ts | 145 ++++++++++++++++++ src/serverFunctions/keywords.ts | 13 ++ src/types/schemas/keywords.ts | 8 + 7 files changed, 276 insertions(+), 35 deletions(-) create mode 100644 src/server/features/keywords/services/research/refresh-metrics.ts diff --git a/src/client/features/saved-keywords/SavedKeywordsHeader.tsx b/src/client/features/saved-keywords/SavedKeywordsHeader.tsx index fff85a0..6ff5e74 100644 --- a/src/client/features/saved-keywords/SavedKeywordsHeader.tsx +++ b/src/client/features/saved-keywords/SavedKeywordsHeader.tsx @@ -1,15 +1,26 @@ -import { ChevronDown, Download, FileDown, Loader2, Sheet } from "lucide-react"; +import { + ChevronDown, + Download, + FileDown, + Loader2, + RefreshCw, + Sheet, +} from "lucide-react"; export function SavedKeywordsHeader({ totalCount, exporting, + metricsRefreshing, onExportCsv, onExportSheets, + onRefreshMetrics, }: { totalCount: number; exporting: "csv" | "sheets" | null; + metricsRefreshing: boolean; onExportCsv: () => void; onExportSheets: () => void; + onRefreshMetrics: () => void; }) { const disabled = totalCount === 0 || exporting != null; @@ -23,40 +34,83 @@ export function SavedKeywordsHeader({

-
- -
    -
  • - -
  • -
  • - -
  • -
+
+
+ +
    +
  • + +
  • +
+
+ +
+ +
    +
  • + +
  • +
  • + +
  • +
+
); diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx index 5016aad..d7cceeb 100644 --- a/src/routes/_project/p/$projectId/saved.tsx +++ b/src/routes/_project/p/$projectId/saved.tsx @@ -35,6 +35,7 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; import { getSavedKeywords, + refreshSavedKeywordMetrics, removeSavedKeywords, updateSavedKeywordTags, } from "@/serverFunctions/keywords"; @@ -192,6 +193,21 @@ function SavedKeywordsPage() { }, }); + const refreshMetricsMutation = useMutation({ + mutationFn: () => refreshSavedKeywordMetrics({ data: { projectId } }), + onSuccess: (result) => { + void invalidateSavedKeywords(); + toast.success( + `Updated stats for ${result.updated} keyword${result.updated !== 1 ? "s" : ""}`, + ); + }, + onError: (error) => { + toast.error( + getStandardErrorMessage(error, "Could not update keyword stats."), + ); + }, + }); + const tagManage = useTagManage(projectId); const exporter = useSavedKeywordsExport({ projectId, @@ -227,8 +243,10 @@ function SavedKeywordsPage() { void exporter.exportFilteredCsv()} onExportSheets={() => void exporter.exportFilteredSheets()} + onRefreshMetrics={() => refreshMetricsMutation.mutate()} />
diff --git a/src/server/features/keywords/services/KeywordResearchService.ts b/src/server/features/keywords/services/KeywordResearchService.ts index 98c7488..6f57634 100644 --- a/src/server/features/keywords/services/KeywordResearchService.ts +++ b/src/server/features/keywords/services/KeywordResearchService.ts @@ -8,6 +8,7 @@ import { exportSavedKeywords, updateSavedKeywordTag, updateSavedKeywordTags, + refreshSavedKeywordMetrics, } from "@/server/features/keywords/services/research"; export const KeywordResearchService = { @@ -20,4 +21,5 @@ export const KeywordResearchService = { updateSavedKeywordTag, deleteSavedKeywordTag, removeSavedKeywords, + refreshSavedKeywordMetrics, } as const; diff --git a/src/server/features/keywords/services/research/index.ts b/src/server/features/keywords/services/research/index.ts index 8eb7e20..e777f00 100644 --- a/src/server/features/keywords/services/research/index.ts +++ b/src/server/features/keywords/services/research/index.ts @@ -9,3 +9,4 @@ export { deleteSavedKeywordTag, removeSavedKeywords, } from "./saved-keywords"; +export { refreshSavedKeywordMetrics } from "./refresh-metrics"; diff --git a/src/server/features/keywords/services/research/refresh-metrics.ts b/src/server/features/keywords/services/research/refresh-metrics.ts new file mode 100644 index 0000000..337f059 --- /dev/null +++ b/src/server/features/keywords/services/research/refresh-metrics.ts @@ -0,0 +1,145 @@ +import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository"; +import { normalizeIntent } from "@/server/features/keywords/services/research/helpers"; +import { createDataforseoClient } from "@/server/lib/dataforseo"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import type { KeywordIntent, MonthlySearch } from "@/types/keywords"; +import type { RefreshSavedKeywordMetricsInput } from "@/types/schemas/keywords"; +import { getKeywordDataProvider } from "@/shared/keyword-locations"; + +const REFRESH_BATCH_SIZE = 700; + +// Match the shape the research/save flow persists so a refresh never degrades +// stored metrics (see research-data.ts mapKeywordDataItems / mapAdsKeywordItems). +function toMonthlySearchesJson( + entries: + | { + year?: number | null; + month?: number | null; + search_volume?: number | null; + }[] + | null + | undefined, +): string { + const trend: MonthlySearch[] = (entries ?? []).map((entry) => ({ + year: entry.year ?? 0, + month: entry.month ?? 0, + searchVolume: entry.search_volume ?? 0, + })); + return JSON.stringify(trend); +} + +type RefreshedMetric = { + searchVolume: number | null; + cpc: number | null; + competition: number | null; + keywordDifficulty: number | null; + intent: KeywordIntent; + monthlySearchesJson: string; +}; + +// Fetch fresh metrics for one homogeneous batch and key them by lowercase +// keyword. Labs covers most countries; the rest fall back to Google Ads, which +// carries volume/CPC/competition but no difficulty or intent. +async function fetchBatchMetrics( + client: ReturnType, + request: { keywords: string[]; locationCode: number; languageCode: string }, + useGoogleAds: boolean, +): Promise> { + const metricsMap = new Map(); + + if (useGoogleAds) { + const items = await client.keywords.adsSearchVolume({ + ...request, + creditFeature: "keyword_research", + }); + for (const item of items) { + if (!item.keyword) continue; + metricsMap.set(item.keyword.toLowerCase(), { + searchVolume: item.search_volume ?? null, + cpc: item.cpc ?? null, + // competition_index is 0-100; the rest of the app stores a 0-1 ratio. + competition: + item.competition_index != null ? item.competition_index / 100 : null, + keywordDifficulty: null, + intent: "unknown", + monthlySearchesJson: toMonthlySearchesJson(item.monthly_searches), + }); + } + return metricsMap; + } + + const items = await client.labs.keywordOverview(request); + for (const item of items) { + if (!item.keyword) continue; + metricsMap.set(item.keyword.toLowerCase(), { + searchVolume: item.keyword_info?.search_volume ?? null, + cpc: item.keyword_info?.cpc ?? null, + competition: item.keyword_info?.competition ?? null, + keywordDifficulty: item.keyword_properties?.keyword_difficulty ?? null, + intent: normalizeIntent(item.search_intent_info?.main_intent), + monthlySearchesJson: toMonthlySearchesJson( + item.keyword_info?.monthly_searches, + ), + }); + } + return metricsMap; +} + +export async function refreshSavedKeywordMetrics( + input: RefreshSavedKeywordMetricsInput, + billingCustomer: BillingCustomerContext, +): Promise<{ updated: number }> { + const { rows } = await KeywordResearchRepository.listSavedKeywordsByProject({ + projectId: input.projectId, + }); + + if (rows.length === 0) return { updated: 0 }; + + const client = createDataforseoClient(billingCustomer); + let updated = 0; + + // Group by (locationCode, languageCode) so each DataForSEO call is homogeneous. + const groups = new Map(); + for (const row of rows) { + const key = `${row.row.locationCode}:${row.row.languageCode}`; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + } + + for (const groupRows of groups.values()) { + const { locationCode, languageCode } = groupRows[0].row; + const useGoogleAds = getKeywordDataProvider(locationCode) === "google_ads"; + + for (let i = 0; i < groupRows.length; i += REFRESH_BATCH_SIZE) { + const batch = groupRows.slice(i, i + REFRESH_BATCH_SIZE); + const keywords = batch.map((r) => r.row.keyword); + const request = { keywords, locationCode, languageCode }; + + const metricsMap = await fetchBatchMetrics(client, request, useGoogleAds); + + await Promise.all( + batch.map((r) => { + const metrics = metricsMap.get(r.row.keyword.toLowerCase()); + if (!metrics) return Promise.resolve(); + return KeywordResearchRepository.upsertKeywordMetric({ + projectId: input.projectId, + keyword: r.row.keyword, + locationCode, + languageCode, + searchVolume: metrics.searchVolume, + cpc: metrics.cpc, + competition: metrics.competition, + keywordDifficulty: metrics.keywordDifficulty, + intent: metrics.intent, + monthlySearchesJson: metrics.monthlySearchesJson, + }); + }), + ); + + updated += metricsMap.size; + } + } + + return { updated }; +} diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index 279c13d..619f101 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -6,6 +6,7 @@ import { getSavedKeywordsSchema, exportSavedKeywordsSchema, removeSavedKeywordsSchema, + refreshSavedKeywordMetricsSchema, serpAnalysisSchema, updateSavedKeywordTagSchema, updateSavedKeywordTagsSchema, @@ -108,6 +109,18 @@ export const removeSavedKeywords = createServerFn({ return KeywordResearchService.removeSavedKeywords(context.projectId, data); }); +export const refreshSavedKeywordMetrics = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => + refreshSavedKeywordMetricsSchema.parse(data), + ) + .handler(async ({ context }) => { + return KeywordResearchService.refreshSavedKeywordMetrics( + { projectId: context.projectId }, + context, + ); + }); + export const getSerpAnalysis = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => serpAnalysisSchema.parse(data)) diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts index 639b532..303d377 100644 --- a/src/types/schemas/keywords.ts +++ b/src/types/schemas/keywords.ts @@ -143,6 +143,10 @@ export const deleteSavedKeywordTagSchema = z.object({ tagId: z.string().min(1), }); +export const refreshSavedKeywordMetricsSchema = z.object({ + projectId: z.string().min(1), +}); + export type ResearchKeywordsInput = z.infer; export type SaveKeywordsInput = z.infer; export type RemoveSavedKeywordsInput = z.infer< @@ -161,6 +165,10 @@ export type UpdateSavedKeywordTagInput = z.infer< export type DeleteSavedKeywordTagInput = z.infer< typeof deleteSavedKeywordTagSchema >; + +export type RefreshSavedKeywordMetricsInput = z.infer< + typeof refreshSavedKeywordMetricsSchema +>; export const serpAnalysisSchema = z.object({ projectId: z.string().min(1), keyword: z.string().min(1),