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({
- 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