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:
parent
e9871bde8d
commit
752561b0ac
@ -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,6 +34,44 @@ export function SavedKeywordsHeader({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 & CPC
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="dropdown dropdown-end">
|
||||
<button
|
||||
type="button"
|
||||
@ -45,7 +94,11 @@ export function SavedKeywordsHeader({
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportSheets}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Sheet className="size-4" />
|
||||
Export to Sheets
|
||||
</button>
|
||||
@ -59,5 +112,6 @@ export function SavedKeywordsHeader({
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -9,3 +9,4 @@ export {
|
||||
deleteSavedKeywordTag,
|
||||
removeSavedKeywords,
|
||||
} from "./saved-keywords";
|
||||
export { refreshSavedKeywordMetrics } from "./refresh-metrics";
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user