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 <bensenescu@gmail.com>
This commit is contained in:
Eugene 2026-07-01 03:52:12 +08:00 committed by GitHub
parent e9871bde8d
commit 752561b0ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 276 additions and 35 deletions

View File

@ -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({
</p>
</div>
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
disabled={disabled}
aria-haspopup="menu"
className={`btn btn-ghost btn-sm gap-1.5 ${disabled ? "btn-disabled" : ""}`}
>
{exporting != null ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
Export
<ChevronDown className="size-3 opacity-60" />
</button>
<ul
tabIndex={0}
role="menu"
className="dropdown-content menu z-10 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li>
<button type="button" onClick={onExportSheets} disabled={disabled}>
<Sheet className="size-4" />
Export to Sheets
</button>
</li>
<li>
<button type="button" onClick={onExportCsv} disabled={disabled}>
<FileDown className="size-4" />
Export CSV
</button>
</li>
</ul>
<div className="flex items-center gap-2">
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
disabled={disabled || metricsRefreshing}
aria-haspopup="menu"
className={`btn btn-ghost btn-sm gap-1.5 ${disabled || metricsRefreshing ? "btn-disabled" : ""}`}
>
<RefreshCw
className={`size-4 ${metricsRefreshing ? "animate-spin" : ""}`}
/>
{metricsRefreshing ? "Updating..." : "Actions"}
<ChevronDown className="size-3 opacity-60" />
</button>
<ul
tabIndex={0}
role="menu"
className="dropdown-content menu z-10 w-64 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li>
<button
type="button"
onClick={onRefreshMetrics}
disabled={disabled || metricsRefreshing}
>
<RefreshCw className="size-4" />
<span className="flex flex-col items-start">
<span>Update keyword stats</span>
<span className="text-xs text-base-content/50">
Volume, difficulty &amp; CPC
</span>
</span>
</button>
</li>
</ul>
</div>
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
disabled={disabled}
aria-haspopup="menu"
className={`btn btn-ghost btn-sm gap-1.5 ${disabled ? "btn-disabled" : ""}`}
>
{exporting != null ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
Export
<ChevronDown className="size-3 opacity-60" />
</button>
<ul
tabIndex={0}
role="menu"
className="dropdown-content menu z-10 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li>
<button
type="button"
onClick={onExportSheets}
disabled={disabled}
>
<Sheet className="size-4" />
Export to Sheets
</button>
</li>
<li>
<button type="button" onClick={onExportCsv} disabled={disabled}>
<FileDown className="size-4" />
Export CSV
</button>
</li>
</ul>
</div>
</div>
</div>
);

View File

@ -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() {
<SavedKeywordsHeader
totalCount={totalCount}
exporting={exporter.exporting}
metricsRefreshing={refreshMetricsMutation.isPending}
onExportCsv={() => void exporter.exportFilteredCsv()}
onExportSheets={() => void exporter.exportFilteredSheets()}
onRefreshMetrics={() => refreshMetricsMutation.mutate()}
/>
<div className="overflow-hidden rounded-lg border border-base-300 bg-base-100">

View File

@ -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;

View File

@ -9,3 +9,4 @@ export {
deleteSavedKeywordTag,
removeSavedKeywords,
} from "./saved-keywords";
export { refreshSavedKeywordMetrics } from "./refresh-metrics";

View File

@ -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<typeof createDataforseoClient>,
request: { keywords: string[]; locationCode: number; languageCode: string },
useGoogleAds: boolean,
): Promise<Map<string, RefreshedMetric>> {
const metricsMap = new Map<string, RefreshedMetric>();
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<string, typeof rows>();
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 };
}

View File

@ -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))

View File

@ -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<typeof researchKeywordsSchema>;
export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
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),