diff --git a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx index 4537565..800fc15 100644 --- a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx +++ b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx @@ -48,9 +48,9 @@ export function RankTrackingConfigModal({ existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE, ); const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40); - const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">( - existingConfig?.scheduleInterval ?? "weekly", - ); + const [schedule, setSchedule] = useState< + RankTrackingConfig["scheduleInterval"] + >(existingConfig?.scheduleInterval ?? "weekly"); const [createdConfigId, setCreatedConfigId] = useState(null); const createMutation = useMutation({ @@ -239,6 +239,7 @@ export function RankTrackingConfigModal({ if ( value === "daily" || value === "weekly" || + value === "monthly" || value === "manual" ) { setSchedule(value); @@ -247,6 +248,7 @@ export function RankTrackingConfigModal({ > + {schedule === "daily" && ( @@ -287,7 +289,8 @@ export function RankTrackingConfigModal({ serpDepth, schedule === "manual" ? "live" : "queued", ); - const checksPerMonth = schedule === "daily" ? 30 : 4; + const checksPerMonth = + schedule === "daily" ? 30 : schedule === "weekly" ? 4 : 1; return (
diff --git a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx index 16516c9..b59fbcd 100644 --- a/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx +++ b/src/client/features/rank-tracking/RankTrackingDomainDetail.tsx @@ -94,7 +94,11 @@ function RankTrackingDomainDetailInner({ const [showFilters, setShowFilters] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const [comparePeriod, setComparePeriod] = useState( - config.scheduleInterval === "daily" ? "1d" : "7d", + config.scheduleInterval === "daily" + ? "1d" + : config.scheduleInterval === "monthly" + ? "30d" + : "7d", ); const [activeDevice, setActiveDevice] = useState<"desktop" | "mobile">( config.devices === "mobile" ? "mobile" : "desktop", diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts index 2df5a69..856a134 100644 --- a/src/db/app.schema.ts +++ b/src/db/app.schema.ts @@ -215,7 +215,7 @@ export const rankTrackingConfigs = sqliteTable( .default("both"), serpDepth: integer("serp_depth").notNull(), scheduleInterval: text("schedule_interval", { - enum: ["daily", "weekly", "manual"], + enum: ["daily", "weekly", "monthly", "manual"], }) .notNull() .default("weekly"), diff --git a/src/server.ts b/src/server.ts index 32ec791..ebc93f6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,7 +20,10 @@ import { import { requestWithPublicOrigin } from "@/server/mcp/public-origin"; import { MCP_ROUTE } from "@/server/mcp/context"; import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport"; -import { computeNextCheckAt } from "@/shared/rank-tracking"; +import { + computeNextCheckAt, + isScheduledRankTrackingInterval, +} from "@/shared/rank-tracking"; import { AUTUMN_WEBHOOK_PATH, handleAutumnWebhookRequest, @@ -147,11 +150,11 @@ export default { `[cron] Skipping config ${config.id} (${config.domain}) — no keywords`, ); // Still advance schedule so this config doesn't stay due forever - const skipInterval = - config.scheduleInterval === "daily" || - config.scheduleInterval === "weekly" - ? config.scheduleInterval - : null; + const skipInterval = isScheduledRankTrackingInterval( + config.scheduleInterval, + ) + ? config.scheduleInterval + : null; if (skipInterval) { await RankTrackingRepository.updateConfig( config.id, @@ -168,11 +171,11 @@ export default { } // Advance nextCheckAt immediately to prevent retry storms if the run fails - const interval = - config.scheduleInterval === "daily" || - config.scheduleInterval === "weekly" - ? config.scheduleInterval - : null; + const interval = isScheduledRankTrackingInterval( + config.scheduleInterval, + ) + ? config.scheduleInterval + : null; if (interval) { await RankTrackingRepository.updateConfig( config.id, diff --git a/src/server/features/rank-tracking/services/RankTrackingService.ts b/src/server/features/rank-tracking/services/RankTrackingService.ts index c988c28..8917c23 100644 --- a/src/server/features/rank-tracking/services/RankTrackingService.ts +++ b/src/server/features/rank-tracking/services/RankTrackingService.ts @@ -16,6 +16,7 @@ import { estimateRankCheckCredits, computeNextCheckAt, devicesCount, + isScheduledRankTrackingInterval, MAX_KEYWORDS_PER_CONFIG, MAX_CONFIGS_PER_PROJECT, } from "@/shared/rank-tracking"; @@ -61,10 +62,9 @@ async function createConfig(input: { const configId = crypto.randomUUID(); const scheduleInterval = input.scheduleInterval ?? "weekly"; - const nextCheckAt = - scheduleInterval === "daily" || scheduleInterval === "weekly" - ? computeNextCheckAt(scheduleInterval) - : null; + const nextCheckAt = isScheduledRankTrackingInterval(scheduleInterval) + ? computeNextCheckAt(scheduleInterval) + : null; await RankTrackingRepository.createConfig({ id: configId, diff --git a/src/shared/rank-tracking.test.ts b/src/shared/rank-tracking.test.ts new file mode 100644 index 0000000..b89f7df --- /dev/null +++ b/src/shared/rank-tracking.test.ts @@ -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", + ); + }); +}); diff --git a/src/shared/rank-tracking.ts b/src/shared/rank-tracking.ts index 73f114c..c0322a5 100644 --- a/src/shared/rank-tracking.ts +++ b/src/shared/rank-tracking.ts @@ -77,6 +77,34 @@ export function estimateRankCheckCredits( // 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. * @@ -88,15 +116,41 @@ export function estimateRankCheckCredits( * Otherwise a random hour (04–09 UTC) and minute are chosen. */ export function computeNextCheckAt( - interval: "daily" | "weekly", + interval: ScheduledRankTrackingInterval, previousNextCheckAt?: string | null, ): 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; if (previousNextCheckAt) { const anchor = new Date(previousNextCheckAt).getTime(); 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(); } @@ -122,6 +176,7 @@ export function scheduleLabel( ): string { if (interval === "daily") return "Daily"; if (interval === "weekly") return "Weekly"; + if (interval === "monthly") return "Monthly"; return "Manual"; }