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:
jalendarreddy97 2026-06-28 17:32:05 -04:00 committed by GitHub
parent 93c2ecf48d
commit e4b0c53b3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 134 additions and 23 deletions

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View 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",
);
});
});

View File

@ -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 (0409 UTC) and minute are chosen. * Otherwise a random hour (0409 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";
} }