Add monthly rank tracking schedule (#45)
* feat: add monthly rank tracking schedule * fix(ci): un-export ScheduledRankTrackingInterval to satisfy knip --------- Co-authored-by: Ben Senescu <bensenescu@gmail.com>
This commit is contained in:
parent
93c2ecf48d
commit
e4b0c53b3b
@ -48,9 +48,9 @@ export function RankTrackingConfigModal({
|
|||||||
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
|
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
|
||||||
);
|
);
|
||||||
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40);
|
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40);
|
||||||
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
|
const [schedule, setSchedule] = useState<
|
||||||
existingConfig?.scheduleInterval ?? "weekly",
|
RankTrackingConfig["scheduleInterval"]
|
||||||
);
|
>(existingConfig?.scheduleInterval ?? "weekly");
|
||||||
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
|
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
@ -239,6 +239,7 @@ export function RankTrackingConfigModal({
|
|||||||
if (
|
if (
|
||||||
value === "daily" ||
|
value === "daily" ||
|
||||||
value === "weekly" ||
|
value === "weekly" ||
|
||||||
|
value === "monthly" ||
|
||||||
value === "manual"
|
value === "manual"
|
||||||
) {
|
) {
|
||||||
setSchedule(value);
|
setSchedule(value);
|
||||||
@ -247,6 +248,7 @@ export function RankTrackingConfigModal({
|
|||||||
>
|
>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">Daily</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly (end of month)</option>
|
||||||
<option value="manual">Manual only</option>
|
<option value="manual">Manual only</option>
|
||||||
</select>
|
</select>
|
||||||
{schedule === "daily" && (
|
{schedule === "daily" && (
|
||||||
@ -287,7 +289,8 @@ export function RankTrackingConfigModal({
|
|||||||
serpDepth,
|
serpDepth,
|
||||||
schedule === "manual" ? "live" : "queued",
|
schedule === "manual" ? "live" : "queued",
|
||||||
);
|
);
|
||||||
const checksPerMonth = schedule === "daily" ? 30 : 4;
|
const checksPerMonth =
|
||||||
|
schedule === "daily" ? 30 : schedule === "weekly" ? 4 : 1;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-base-200/50 px-3 py-2.5 text-xs text-base-content/70 space-y-0.5">
|
<div className="rounded-lg bg-base-200/50 px-3 py-2.5 text-xs text-base-content/70 space-y-0.5">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -94,7 +94,11 @@ function RankTrackingDomainDetailInner({
|
|||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||||
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>(
|
const [comparePeriod, setComparePeriod] = useState<ComparePeriod>(
|
||||||
config.scheduleInterval === "daily" ? "1d" : "7d",
|
config.scheduleInterval === "daily"
|
||||||
|
? "1d"
|
||||||
|
: config.scheduleInterval === "monthly"
|
||||||
|
? "30d"
|
||||||
|
: "7d",
|
||||||
);
|
);
|
||||||
const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">(
|
const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">(
|
||||||
config.devices === "mobile" ? "mobile" : "desktop",
|
config.devices === "mobile" ? "mobile" : "desktop",
|
||||||
|
|||||||
@ -215,7 +215,7 @@ export const rankTrackingConfigs = sqliteTable(
|
|||||||
.default("both"),
|
.default("both"),
|
||||||
serpDepth: integer("serp_depth").notNull(),
|
serpDepth: integer("serp_depth").notNull(),
|
||||||
scheduleInterval: text("schedule_interval", {
|
scheduleInterval: text("schedule_interval", {
|
||||||
enum: ["daily", "weekly", "manual"],
|
enum: ["daily", "weekly", "monthly", "manual"],
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("weekly"),
|
.default("weekly"),
|
||||||
|
|||||||
@ -20,7 +20,10 @@ import {
|
|||||||
import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
|
import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
import { MCP_ROUTE } from "@/server/mcp/context";
|
import { MCP_ROUTE } from "@/server/mcp/context";
|
||||||
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
||||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
import {
|
||||||
|
computeNextCheckAt,
|
||||||
|
isScheduledRankTrackingInterval,
|
||||||
|
} from "@/shared/rank-tracking";
|
||||||
import {
|
import {
|
||||||
AUTUMN_WEBHOOK_PATH,
|
AUTUMN_WEBHOOK_PATH,
|
||||||
handleAutumnWebhookRequest,
|
handleAutumnWebhookRequest,
|
||||||
@ -147,9 +150,9 @@ export default {
|
|||||||
`[cron] Skipping config ${config.id} (${config.domain}) — no keywords`,
|
`[cron] Skipping config ${config.id} (${config.domain}) — no keywords`,
|
||||||
);
|
);
|
||||||
// Still advance schedule so this config doesn't stay due forever
|
// Still advance schedule so this config doesn't stay due forever
|
||||||
const skipInterval =
|
const skipInterval = isScheduledRankTrackingInterval(
|
||||||
config.scheduleInterval === "daily" ||
|
config.scheduleInterval,
|
||||||
config.scheduleInterval === "weekly"
|
)
|
||||||
? config.scheduleInterval
|
? config.scheduleInterval
|
||||||
: null;
|
: null;
|
||||||
if (skipInterval) {
|
if (skipInterval) {
|
||||||
@ -168,9 +171,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Advance nextCheckAt immediately to prevent retry storms if the run fails
|
// Advance nextCheckAt immediately to prevent retry storms if the run fails
|
||||||
const interval =
|
const interval = isScheduledRankTrackingInterval(
|
||||||
config.scheduleInterval === "daily" ||
|
config.scheduleInterval,
|
||||||
config.scheduleInterval === "weekly"
|
)
|
||||||
? config.scheduleInterval
|
? config.scheduleInterval
|
||||||
: null;
|
: null;
|
||||||
if (interval) {
|
if (interval) {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
estimateRankCheckCredits,
|
estimateRankCheckCredits,
|
||||||
computeNextCheckAt,
|
computeNextCheckAt,
|
||||||
devicesCount,
|
devicesCount,
|
||||||
|
isScheduledRankTrackingInterval,
|
||||||
MAX_KEYWORDS_PER_CONFIG,
|
MAX_KEYWORDS_PER_CONFIG,
|
||||||
MAX_CONFIGS_PER_PROJECT,
|
MAX_CONFIGS_PER_PROJECT,
|
||||||
} from "@/shared/rank-tracking";
|
} from "@/shared/rank-tracking";
|
||||||
@ -61,8 +62,7 @@ async function createConfig(input: {
|
|||||||
|
|
||||||
const configId = crypto.randomUUID();
|
const configId = crypto.randomUUID();
|
||||||
const scheduleInterval = input.scheduleInterval ?? "weekly";
|
const scheduleInterval = input.scheduleInterval ?? "weekly";
|
||||||
const nextCheckAt =
|
const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval)
|
||||||
scheduleInterval === "daily" || scheduleInterval === "weekly"
|
|
||||||
? computeNextCheckAt(scheduleInterval)
|
? computeNextCheckAt(scheduleInterval)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
46
src/shared/rank-tracking.test.ts
Normal file
46
src/shared/rank-tracking.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { computeNextCheckAt, scheduleLabel } from "./rank-tracking";
|
||||||
|
|
||||||
|
describe("rank tracking schedules", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels monthly schedules", () => {
|
||||||
|
expect(scheduleLabel("monthly")).toBe("Monthly");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules new monthly configs for the end of the current month", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z"));
|
||||||
|
vi.spyOn(Math, "random").mockReturnValueOnce(0).mockReturnValueOnce(0);
|
||||||
|
|
||||||
|
expect(computeNextCheckAt("monthly")).toBe("2026-01-31T04:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves new monthly configs to next month when this month's run time has passed", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-31T10:00:00.000Z"));
|
||||||
|
vi.spyOn(Math, "random").mockReturnValueOnce(0).mockReturnValueOnce(0);
|
||||||
|
|
||||||
|
expect(computeNextCheckAt("monthly")).toBe("2026-02-28T04:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances monthly schedules on month end across shorter months", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z"));
|
||||||
|
|
||||||
|
expect(computeNextCheckAt("monthly", "2026-01-31T05:30:00.000Z")).toBe(
|
||||||
|
"2026-02-28T05:30:00.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps advancing monthly schedules until the next check is in the future", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-03-10T00:00:00.000Z"));
|
||||||
|
|
||||||
|
expect(computeNextCheckAt("monthly", "2026-01-31T05:30:00.000Z")).toBe(
|
||||||
|
"2026-03-31T05:30:00.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -77,6 +77,34 @@ export function estimateRankCheckCredits(
|
|||||||
// Schedule
|
// Schedule
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ScheduledRankTrackingInterval = Exclude<
|
||||||
|
RankTrackingConfig["scheduleInterval"],
|
||||||
|
"manual"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function isScheduledRankTrackingInterval(
|
||||||
|
interval: RankTrackingConfig["scheduleInterval"],
|
||||||
|
): interval is ScheduledRankTrackingInterval {
|
||||||
|
return interval !== "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfMonthWithTime(source: Date, monthOffset = 0): Date {
|
||||||
|
const endOfMonth = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
source.getUTCFullYear(),
|
||||||
|
source.getUTCMonth() + monthOffset + 1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
endOfMonth.setUTCHours(
|
||||||
|
source.getUTCHours(),
|
||||||
|
source.getUTCMinutes(),
|
||||||
|
source.getUTCSeconds(),
|
||||||
|
source.getUTCMilliseconds(),
|
||||||
|
);
|
||||||
|
return endOfMonth;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the next check time for a scheduled config.
|
* Compute the next check time for a scheduled config.
|
||||||
*
|
*
|
||||||
@ -88,15 +116,41 @@ export function estimateRankCheckCredits(
|
|||||||
* Otherwise a random hour (04–09 UTC) and minute are chosen.
|
* Otherwise a random hour (04–09 UTC) and minute are chosen.
|
||||||
*/
|
*/
|
||||||
export function computeNextCheckAt(
|
export function computeNextCheckAt(
|
||||||
interval: "daily" | "weekly",
|
interval: ScheduledRankTrackingInterval,
|
||||||
previousNextCheckAt?: string | null,
|
previousNextCheckAt?: string | null,
|
||||||
): string {
|
): string {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (interval === "monthly") {
|
||||||
|
if (previousNextCheckAt) {
|
||||||
|
const anchor = new Date(previousNextCheckAt);
|
||||||
|
let monthOffset = 1;
|
||||||
|
let nextDate = endOfMonthWithTime(anchor, monthOffset);
|
||||||
|
while (nextDate.getTime() <= now) {
|
||||||
|
monthOffset += 1;
|
||||||
|
nextDate = endOfMonthWithTime(anchor, monthOffset);
|
||||||
|
}
|
||||||
|
return nextDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = 4 + Math.floor(Math.random() * 6);
|
||||||
|
const minute = Math.floor(Math.random() * 60);
|
||||||
|
const nextDate = endOfMonthWithTime(new Date());
|
||||||
|
nextDate.setUTCHours(hour, minute, 0, 0);
|
||||||
|
if (nextDate.getTime() <= now) {
|
||||||
|
const followingMonth = endOfMonthWithTime(nextDate, 1);
|
||||||
|
followingMonth.setUTCHours(hour, minute, 0, 0);
|
||||||
|
return followingMonth.toISOString();
|
||||||
|
}
|
||||||
|
return nextDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
const daysAhead = interval === "daily" ? 1 : 7;
|
const daysAhead = interval === "daily" ? 1 : 7;
|
||||||
|
|
||||||
if (previousNextCheckAt) {
|
if (previousNextCheckAt) {
|
||||||
const anchor = new Date(previousNextCheckAt).getTime();
|
const anchor = new Date(previousNextCheckAt).getTime();
|
||||||
const intervalMs = daysAhead * 86_400_000;
|
const intervalMs = daysAhead * 86_400_000;
|
||||||
const steps = Math.floor(Math.max(0, Date.now() - anchor) / intervalMs) + 1;
|
const steps = Math.floor(Math.max(0, now - anchor) / intervalMs) + 1;
|
||||||
return new Date(anchor + steps * intervalMs).toISOString();
|
return new Date(anchor + steps * intervalMs).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,6 +176,7 @@ export function scheduleLabel(
|
|||||||
): string {
|
): string {
|
||||||
if (interval === "daily") return "Daily";
|
if (interval === "daily") return "Daily";
|
||||||
if (interval === "weekly") return "Weekly";
|
if (interval === "weekly") return "Weekly";
|
||||||
|
if (interval === "monthly") return "Monthly";
|
||||||
return "Manual";
|
return "Manual";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user