172 lines
4.8 KiB
TypeScript
172 lines
4.8 KiB
TypeScript
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
|
import { AppError } from "@/server/lib/errors";
|
|
import type { ComparePeriod } from "@/types/schemas/rank-tracking";
|
|
import type {
|
|
RankTrackingDeviceResult,
|
|
RankTrackingRow,
|
|
} from "@/types/schemas/rank-tracking";
|
|
|
|
type SnapshotRow = Awaited<
|
|
ReturnType<typeof RankTrackingRepository.getLatestSnapshotsForKeywords>
|
|
>[0];
|
|
|
|
const PERIOD_DAYS: Record<ComparePeriod, number> = {
|
|
"1d": 1,
|
|
"7d": 7,
|
|
"30d": 30,
|
|
"90d": 90,
|
|
};
|
|
|
|
export async function getLatestResults(
|
|
configId: string,
|
|
projectId: string,
|
|
comparePeriod: ComparePeriod = "7d",
|
|
): Promise<{
|
|
rows: RankTrackingRow[];
|
|
run: { id: string; lastCheckedAt: string } | null;
|
|
}> {
|
|
const config = await RankTrackingRepository.getConfigById({
|
|
configId,
|
|
projectId,
|
|
});
|
|
if (!config) {
|
|
throw new AppError("INTERNAL_ERROR", "Rank tracking config not found");
|
|
}
|
|
|
|
const activeKeywords =
|
|
await RankTrackingRepository.getKeywordsForConfig(configId);
|
|
|
|
// Get the latest snapshot per keyword per device (across all completed runs)
|
|
const currentSnapshots =
|
|
await RankTrackingRepository.getLatestSnapshotsForKeywords(configId);
|
|
|
|
// Get comparison snapshots from before the target date
|
|
const days = PERIOD_DAYS[comparePeriod];
|
|
const targetDate = new Date(
|
|
Date.now() - days * 24 * 60 * 60 * 1000,
|
|
).toISOString();
|
|
|
|
const comparisonSnapshots =
|
|
await RankTrackingRepository.getSnapshotsBeforeDate(configId, targetDate);
|
|
|
|
const previousPositions = new Map<string, number | null>();
|
|
for (const snap of comparisonSnapshots) {
|
|
previousPositions.set(
|
|
`${snap.trackingKeywordId}:${snap.device}`,
|
|
snap.position,
|
|
);
|
|
}
|
|
|
|
// Fallback: for keyword+device combos with no comparison snapshot before
|
|
// the target date, use the earliest available snapshot as a baseline.
|
|
const missingKeywordIds: string[] = [];
|
|
for (const snap of currentSnapshots) {
|
|
const key = `${snap.trackingKeywordId}:${snap.device}`;
|
|
if (!previousPositions.has(key)) {
|
|
missingKeywordIds.push(snap.trackingKeywordId);
|
|
}
|
|
}
|
|
|
|
if (missingKeywordIds.length > 0) {
|
|
const uniqueMissingIds = [...new Set(missingKeywordIds)];
|
|
const earliestSnapshots =
|
|
await RankTrackingRepository.getEarliestSnapshotsForKeywords(
|
|
configId,
|
|
uniqueMissingIds,
|
|
);
|
|
for (const snap of earliestSnapshots) {
|
|
const key = `${snap.trackingKeywordId}:${snap.device}`;
|
|
if (!previousPositions.has(key)) {
|
|
previousPositions.set(key, snap.position);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build result rows
|
|
const rows = new Map<string, RankTrackingRow>(
|
|
activeKeywords.map((keyword) => [
|
|
keyword.id,
|
|
{
|
|
trackingKeywordId: keyword.id,
|
|
keyword: keyword.keyword,
|
|
searchVolume: keyword.searchVolume,
|
|
keywordDifficulty: keyword.keywordDifficulty,
|
|
cpc: keyword.cpc,
|
|
desktop: createEmptyDeviceResult(
|
|
previousPositions.get(`${keyword.id}:desktop`) ?? null,
|
|
),
|
|
mobile: createEmptyDeviceResult(
|
|
previousPositions.get(`${keyword.id}:mobile`) ?? null,
|
|
),
|
|
},
|
|
]),
|
|
);
|
|
|
|
// Determine the most recent snapshot time for the run info
|
|
let latestRunId: string | null = null;
|
|
let latestStartedAt: string | null = null;
|
|
|
|
for (const snapshot of currentSnapshots) {
|
|
const row = rows.get(snapshot.trackingKeywordId);
|
|
if (!row) continue;
|
|
row[snapshot.device] = toDeviceResult(
|
|
snapshot,
|
|
previousPositions.get(
|
|
`${snapshot.trackingKeywordId}:${snapshot.device}`,
|
|
) ?? null,
|
|
);
|
|
|
|
// Track the most recent run for the header display
|
|
if (!latestStartedAt || snapshot.checkedAt > latestStartedAt) {
|
|
latestRunId = snapshot.runId;
|
|
latestStartedAt = snapshot.checkedAt;
|
|
}
|
|
}
|
|
|
|
return {
|
|
rows: activeKeywords
|
|
.map((keyword) => rows.get(keyword.id))
|
|
.filter((row): row is RankTrackingRow => row != null),
|
|
run:
|
|
latestRunId && latestStartedAt
|
|
? { id: latestRunId, lastCheckedAt: latestStartedAt }
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function parseSerpFeatures(raw: string | null): string[] {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.filter((item): item is string => typeof item === "string");
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function createEmptyDeviceResult(
|
|
previousPosition: number | null,
|
|
): RankTrackingDeviceResult {
|
|
return {
|
|
position: null,
|
|
previousPosition,
|
|
rankingUrl: null,
|
|
serpFeatures: [],
|
|
};
|
|
}
|
|
|
|
function toDeviceResult(
|
|
snapshot: SnapshotRow,
|
|
previousPosition: number | null,
|
|
): RankTrackingDeviceResult {
|
|
return {
|
|
position: snapshot.position,
|
|
previousPosition,
|
|
rankingUrl: snapshot.url,
|
|
serpFeatures: parseSerpFeatures(snapshot.serpFeatures),
|
|
};
|
|
}
|