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,
|
||||
);
|
||||
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<string | null>(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({
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly (end of month)</option>
|
||||
<option value="manual">Manual only</option>
|
||||
</select>
|
||||
{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 (
|
||||
<div className="rounded-lg bg-base-200/50 px-3 py-2.5 text-xs text-base-content/70 space-y-0.5">
|
||||
<div>
|
||||
|
||||
@ -94,7 +94,11 @@ function RankTrackingDomainDetailInner({
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
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">(
|
||||
config.devices === "mobile" ? "mobile" : "desktop",
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user