Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run typecheck
npm run test:cov
Comment on lines 1 to 2
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Husky hook no longer has a shebang or Husky bootstrap (the removed lines). Without a shebang, Git may fail to execute the hook with an "exec format" error, and without husky.sh you lose Husky’s environment setup/opt-out behavior. Restore the standard Husky header at the top of this hook.

Copilot uses AI. Check for mistakes.
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"name": "@ciscode/scheduler-kit",
"version": "0.0.0",
"description": "Typed job scheduler for NestJS. @Cron and @Interval and @Timeout decorators. Dynamic runtime scheduling. Concurrent execution guard.",
"description": "NestJS advanced scheduler wrapper with dynamic control and concurrency guards.",
"author": "CisCode",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/CISCODE-MA/SchedulerKit.git"
"url": "git+https://github.com/CISCODE-MA/"
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repository URL was changed to the GitHub org root ("git+https://github.com/CISCODE-MA/") rather than the actual repository. This breaks npm metadata (and tooling that links back to source). Update it to the full repo URL (typically including the repo name and .git suffix).

Suggested change
"url": "git+https://github.com/CISCODE-MA/"
"url": "git+https://github.com/CISCODE-MA/scheduler-kit.git"

Copilot uses AI. Check for mistakes.
},
"license": "MIT",
"files": [
Expand Down Expand Up @@ -45,26 +45,26 @@
"peerDependencies": {
"@nestjs/common": "^10 || ^11",
"@nestjs/core": "^10 || ^11",
"@nestjs/platform-express": "^10 || ^11",
"@nestjs/schedule": "^4 || ^5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7"
},
"dependencies": {
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1"
},
"devDependencies": {
"@changesets/cli": "^2.27.7",
"@eslint/js": "^9.18.0",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/mapped-types": "^2.0.0",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^6.1.1",
"@nestjs/testing": "^10.4.0",
Comment on lines 45 to 60
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nestjs/schedule version ranges are inconsistent: peerDependencies restrict to "^4 || ^5", but devDependencies installs "^6.1.1". Align these (either bump the peer range to include the dev version, or downgrade devDependency) to avoid local builds passing while consumers hit peer/dependency conflicts.

Copilot uses AI. Check for mistakes.
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"cron": "^4.4.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.5",
Comment on lines 60 to +67
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "cron" package is listed in devDependencies, but there are no imports/usages of it anywhere in the repo. If this kit doesn’t directly use the library, drop the dependency (or add the intended usage) to avoid unnecessary install surface area.

Copilot uses AI. Check for mistakes.
"globals": "^16.5.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
Expand Down
113 changes: 113 additions & 0 deletions src/cron-builder.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { cron } from "./cron-builder";

describe("cron builder", () => {
describe("every(n).minutes()", () => {
it("should build every-5-minutes expression", () => {
expect(cron.every(5).minutes()).toBe("*/5 * * * *");
});

it("should build every-1-minute expression", () => {
expect(cron.every(1).minutes()).toBe("*/1 * * * *");
});

it("should build every-30-minutes expression", () => {
expect(cron.every(30).minutes()).toBe("*/30 * * * *");
});
});

describe("every(n).hours()", () => {
it("should build every-2-hours expression", () => {
expect(cron.every(2).hours()).toBe("0 */2 * * *");
});

it("should build every-6-hours expression", () => {
expect(cron.every(6).hours()).toBe("0 */6 * * *");
});
});

describe("dailyAt()", () => {
it('should parse "9am" → 09:00', () => {
expect(cron.dailyAt("9am")).toBe("0 9 * * *");
});

it('should parse "9pm" → 21:00', () => {
expect(cron.dailyAt("9pm")).toBe("0 21 * * *");
});

it('should parse "9:30am" → 09:30', () => {
expect(cron.dailyAt("9:30am")).toBe("30 9 * * *");
});

it('should parse "9:30pm" → 21:30', () => {
expect(cron.dailyAt("9:30pm")).toBe("30 21 * * *");
});

it('should parse "12am" (midnight) → 00:00', () => {
expect(cron.dailyAt("12am")).toBe("0 0 * * *");
});

it('should parse "12pm" (noon) → 12:00', () => {
expect(cron.dailyAt("12pm")).toBe("0 12 * * *");
});

it('should parse 24-hour "14:30" → 14:30', () => {
expect(cron.dailyAt("14:30")).toBe("30 14 * * *");
});

it('should parse 24-hour "00:00" → midnight', () => {
expect(cron.dailyAt("00:00")).toBe("0 0 * * *");
});

it("should throw on invalid time format", () => {
expect(() => cron.dailyAt("invalid")).toThrow();
});
});

describe("weekdaysAt()", () => {
it("should produce Mon–Fri expression", () => {
expect(cron.weekdaysAt("9am")).toBe("0 9 * * 1-5");
});

it("should respect time with minutes", () => {
expect(cron.weekdaysAt("9:30am")).toBe("30 9 * * 1-5");
});
});

describe("weekendsAt()", () => {
it("should produce Sat+Sun expression", () => {
expect(cron.weekendsAt("10am")).toBe("0 10 * * 6,0");
});
});

describe("weeklyOn()", () => {
it("should build monday expression", () => {
expect(cron.weeklyOn("monday", "9am")).toBe("0 9 * * 1");
});

it("should build friday expression", () => {
expect(cron.weeklyOn("friday", "6pm")).toBe("0 18 * * 5");
});

it("should build sunday expression", () => {
expect(cron.weeklyOn("sunday", "12am")).toBe("0 0 * * 0");
});

it("should build saturday expression", () => {
expect(cron.weeklyOn("saturday", "8am")).toBe("0 8 * * 6");
});
});

describe("monthlyOn()", () => {
it("should build 1st of month at midnight", () => {
expect(cron.monthlyOn(1, "12am")).toBe("0 0 1 * *");
});

it("should build 15th of month at noon", () => {
expect(cron.monthlyOn(15, "12pm")).toBe("0 12 15 * *");
});

it("should build last-ish day at 9am", () => {
expect(cron.monthlyOn(28, "9am")).toBe("0 9 28 * *");
});
});
});
133 changes: 133 additions & 0 deletions src/cron-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// ─── Types ─────────────────────────────────────────────────────────────────────

export type DayOfWeek =
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday";

// ─── Internal helpers ──────────────────────────────────────────────────────────

const DAY_MAP: Record<DayOfWeek, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};

/**
* Parse a human-readable time string into { hour, minute }.
*
* Accepted formats:
* '9am' → 09:00
* '9pm' → 21:00
* '9:30am' → 09:30
* '9:30pm' → 21:30
* '09:00' → 09:00 (24-hour)
* '21:30' → 21:30 (24-hour)
*/
function parseTime(time: string): { hour: number; minute: number } {
const ampm = /^(\d{1,2})(?::(\d{2}))?(am|pm)$/i.exec(time.trim());
if (ampm) {
let hour = parseInt(ampm[1] as string, 10);
const minute = ampm[2] ? parseInt(ampm[2], 10) : 0;
const period = (ampm[3] as string).toLowerCase();
if (period === "pm" && hour !== 12) hour += 12;
if (period === "am" && hour === 12) hour = 0;
return { hour, minute };
}
const h24 = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (h24) {
return { hour: parseInt(h24[1] as string, 10), minute: parseInt(h24[2] as string, 10) };
Comment on lines +36 to +47
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseTime() accepts out-of-range times like "99:99" or "25pm" because it only validates format, not numeric bounds. This can generate invalid cron expressions that fail at runtime. Add range checks (hour 0–23, minute 0–59; plus 1–12 for am/pm input) and throw a clear error when out of range.

Suggested change
const ampm = /^(\d{1,2})(?::(\d{2}))?(am|pm)$/i.exec(time.trim());
if (ampm) {
let hour = parseInt(ampm[1] as string, 10);
const minute = ampm[2] ? parseInt(ampm[2], 10) : 0;
const period = (ampm[3] as string).toLowerCase();
if (period === "pm" && hour !== 12) hour += 12;
if (period === "am" && hour === 12) hour = 0;
return { hour, minute };
}
const h24 = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (h24) {
return { hour: parseInt(h24[1] as string, 10), minute: parseInt(h24[2] as string, 10) };
const trimmedTime = time.trim();
const ampm = /^(\d{1,2})(?::(\d{2}))?(am|pm)$/i.exec(trimmedTime);
if (ampm) {
const rawHour = parseInt(ampm[1] as string, 10);
const minute = ampm[2] ? parseInt(ampm[2], 10) : 0;
if (rawHour < 1 || rawHour > 12) {
throw new Error(`Cannot parse time: "${time}". Hour must be between 1 and 12 for am/pm input.`);
}
if (minute < 0 || minute > 59) {
throw new Error(`Cannot parse time: "${time}". Minute must be between 0 and 59.`);
}
let hour = rawHour;
const period = (ampm[3] as string).toLowerCase();
if (period === "pm" && hour !== 12) hour += 12;
if (period === "am" && hour === 12) hour = 0;
return { hour, minute };
}
const h24 = /^(\d{1,2}):(\d{2})$/.exec(trimmedTime);
if (h24) {
const hour = parseInt(h24[1] as string, 10);
const minute = parseInt(h24[2] as string, 10);
if (hour < 0 || hour > 23) {
throw new Error(`Cannot parse time: "${time}". Hour must be between 0 and 23 for 24-hour input.`);
}
if (minute < 0 || minute > 59) {
throw new Error(`Cannot parse time: "${time}". Minute must be between 0 and 59.`);
}
return { hour, minute };

Copilot uses AI. Check for mistakes.
}
throw new Error(`Cannot parse time: "${time}". ` + `Use "9am", "9:30pm", "14:30", etc.`);
}

// ─── Public API ────────────────────────────────────────────────────────────────

/**
* Fluent cron expression builder — no cron syntax required.
*
* @example
* import { cron } from '@ciscode/scheduler-kit';
*
* cron.every(5).minutes() // "* /5 * * * *"
* cron.every(2).hours() // "0 * /2 * * *"
Comment on lines +60 to +61
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example outputs in this docblock have extra spaces (e.g. "* /5" and "0 * /2") which are not valid cron syntax and don’t match what the functions actually return ("*/5", "0 */2"). Please correct the example strings to avoid misleading consumers.

Suggested change
* cron.every(5).minutes() // "* /5 * * * *"
* cron.every(2).hours() // "0 * /2 * * *"
* cron.every(5).minutes() // "*/5 * * * * *"
* cron.every(2).hours() // "0 */2 * * * *"

Copilot uses AI. Check for mistakes.
* cron.dailyAt('9am') // "0 9 * * *"
* cron.dailyAt('9:30pm') // "30 21 * * *"
* cron.weekdaysAt('9am') // "0 9 * * 1-5"
* cron.weekendsAt('10am') // "0 10 * * 6,0"
* cron.weeklyOn('monday', '9am') // "0 9 * * 1"
* cron.monthlyOn(1, '9am') // "0 9 1 * *" (1st of every month)
* cron.monthlyOn(15, '12pm') // "0 12 15 * *" (15th of every month)
*/
export const cron = {
/**
* Repeat every N minutes or hours.
* @example
* cron.every(5).minutes() // every 5 minutes
* cron.every(2).hours() // every 2 hours
*/
every(n: number) {
return {
minutes: (): string => `*/${n} * * * *`,
hours: (): string => `0 */${n} * * *`,
};
},
Comment on lines +77 to +82
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cron.every(n) and cron.monthlyOn(dayOfMonth, ...) don’t validate inputs (e.g., n <= 0 / non-integers, dayOfMonth outside 1–31). These produce invalid cron strings. Validate these arguments and throw an error for invalid values.

Copilot uses AI. Check for mistakes.

/**
* Once a day at the specified time.
* @example
* cron.dailyAt('9am') // every day at 09:00
* cron.dailyAt('9:30pm') // every day at 21:30
* cron.dailyAt('00:00') // every day at midnight
*/
dailyAt(time: string): string {
const { hour, minute } = parseTime(time);
return `${minute} ${hour} * * *`;
},

/**
* Monday–Friday only at the specified time.
* @example cron.weekdaysAt('9am')
*/
weekdaysAt(time: string): string {
const { hour, minute } = parseTime(time);
return `${minute} ${hour} * * 1-5`;
},

/**
* Saturday + Sunday only at the specified time.
* @example cron.weekendsAt('10am')
*/
weekendsAt(time: string): string {
const { hour, minute } = parseTime(time);
return `${minute} ${hour} * * 6,0`;
},

/**
* Once a week on a specific day at the specified time.
* @example cron.weeklyOn('monday', '9am')
*/
weeklyOn(day: DayOfWeek, time: string): string {
const { hour, minute } = parseTime(time);
return `${minute} ${hour} * * ${DAY_MAP[day]}`;
},

/**
* Once a month on a specific day-of-month at the specified time.
* @example
* cron.monthlyOn(1, '9am') // 1st of every month at 09:00
* cron.monthlyOn(15, '12pm') // 15th of every month at 12:00
*/
monthlyOn(dayOfMonth: number, time: string): string {
const { hour, minute } = parseTime(time);
return `${minute} ${hour} ${dayOfMonth} * *`;
},
} as const;
75 changes: 75 additions & 0 deletions src/cron-expression.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { CronExpression } from "./cron-expression";

describe("CronExpression", () => {
it("should export EVERY_MINUTE", () => {
expect(CronExpression.EVERY_MINUTE).toBe("* * * * *");
});

it("should export EVERY_5_MINUTES", () => {
expect(CronExpression.EVERY_5_MINUTES).toBe("*/5 * * * *");
});

it("should export EVERY_10_MINUTES", () => {
expect(CronExpression.EVERY_10_MINUTES).toBe("*/10 * * * *");
});

it("should export EVERY_15_MINUTES", () => {
expect(CronExpression.EVERY_15_MINUTES).toBe("*/15 * * * *");
});

it("should export EVERY_30_MINUTES", () => {
expect(CronExpression.EVERY_30_MINUTES).toBe("*/30 * * * *");
});

it("should export EVERY_HOUR", () => {
expect(CronExpression.EVERY_HOUR).toBe("0 * * * *");
});

it("should export EVERY_2_HOURS", () => {
expect(CronExpression.EVERY_2_HOURS).toBe("0 */2 * * *");
});

it("should export EVERY_6_HOURS", () => {
expect(CronExpression.EVERY_6_HOURS).toBe("0 */6 * * *");
});

it("should export EVERY_12_HOURS", () => {
expect(CronExpression.EVERY_12_HOURS).toBe("0 */12 * * *");
});

it("should export EVERY_DAY_AT_MIDNIGHT", () => {
expect(CronExpression.EVERY_DAY_AT_MIDNIGHT).toBe("0 0 * * *");
});

it("should export EVERY_DAY_AT_9AM", () => {
expect(CronExpression.EVERY_DAY_AT_9AM).toBe("0 9 * * *");
});

it("should export EVERY_DAY_AT_NOON", () => {
expect(CronExpression.EVERY_DAY_AT_NOON).toBe("0 12 * * *");
});

it("should export EVERY_DAY_AT_6PM", () => {
expect(CronExpression.EVERY_DAY_AT_6PM).toBe("0 18 * * *");
});

it("should export EVERY_WEEKDAY_9AM", () => {
expect(CronExpression.EVERY_WEEKDAY_9AM).toBe("0 9 * * 1-5");
});

it("should export EVERY_WEEKEND_MIDNIGHT", () => {
expect(CronExpression.EVERY_WEEKEND_MIDNIGHT).toBe("0 0 * * 6,0");
});

it("should export EVERY_MONDAY_9AM", () => {
expect(CronExpression.EVERY_MONDAY_9AM).toBe("0 9 * * 1");
});

it("should export EVERY_SUNDAY_MIDNIGHT", () => {
expect(CronExpression.EVERY_SUNDAY_MIDNIGHT).toBe("0 0 * * 0");
});

it("should export FIRST_OF_MONTH", () => {
expect(CronExpression.FIRST_OF_MONTH).toBe("0 0 1 * *");
});
});
Loading
Loading