diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d25c3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +.DS_Store + +# Dependency directories +node_modules/ + +# Build folder +dist + +# Lock files +yarn.lock +package-lock.json + +# Compressed files +*.tgz +*.tar.gz +*.zip diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6d68287 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "arrowParens": "always", + "endOfLine": "auto", + "jsxSingleQuote": true, + "semi": false, + "singleQuote": true, + "trailingComma": "es5" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..84ce59e --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Scheduler + +Scheduler is a TypeScript class that provides methods for managing time-based tasks, such as starting and stopping timeouts and intervals at specific times. + +## Key Concepts + +### TimeOfDay + +A `TimeOfDay` object is used to represent a specific time of day. It is an object containing the `hour`, `minute`, and `seconds`. + +```typescript +export interface TimeOfDay { + hour: number + minute?: number + seconds?: number +} +``` + +### TimeUntil + +A `TimeUntil` object is used to represent a specific time until a certain event. It can represent time until a certain date, milliseconds from now, or a specific time of day. + +```typescript +export type TimeUntil = { + timeOfDay?: TimeOfDay + date?: Date + ms?: number +} +``` + +## Usage + +The `Scheduler` class provides two main static methods: `startTimeout` and `startInterval`. + +### startTimeout + +The `startTimeout` method starts a timeout that calls a given function after a specific delay. The delay is calculated based on the `TimeUntil` object passed to it. The method returns a `StopFunction` (see below). + +```typescript +public static startTimeout( + timerFunc: Function, + start: TimeUntil +): StopFunction; +``` + +### startInterval + +The `startInterval` method starts an interval that calls a given function repeatedly with a fixed time delay between each call. Like `startTimeout`, the initial delay is calculated based on a `TimeUntil` object. The method returns a `StopFunction` (see below). + +```typescript +public static startInterval( + intervalFunc: Function, + intervalMS: number, + start?: TimeUntil +): StopFunction; +``` + +## Stop Functions + +Both the `startTimeout` and `startInterval` methods return a `StopFunction`. This function can be called to cancel a timeout or interval. + +When called with no arguments, the `StopFunction` stops the timeout or interval immediately. If called with a `TimeUntil` argument, it schedules a stop at the specified time. + +Here is the type definition of a `StopFunction`: + +```typescript +type StopFunction = (stopTime?: TimeUntil) => StopCancelFunction | null +``` + +## Stop Cancel Functions + +The `StopFunction` returns a `StopCancelFunction` when called. This function can be called to cancel a scheduled stop. + +```typescript +type StopCancelFunction = (stopRunning: boolean = false) => void +``` + +In the `StopCancelFunction`, if the `stopRunning` parameter is `true`, it stops the timeout or interval immediately. If `stopRunning` is `false`, it cancels the scheduled stop. + +## Examples + +1. Start a timeout that says "Hello, world!" after 10 seconds, then stop it after 5 seconds. + + ```typescript + const sayHello = () => console.log('Hello, world!') + + // Start a timeout that says "Hello, world!" after 10 seconds, and stop it after 5 seconds. + let stopTimeout = Scheduler.startTimeout(sayHello, { ms: 10000 }) + stopTimeout({ ms: 5000 }) + ``` + +2. Using startTimeout with a specific time of day + + ```typescript + const goodMorning = () => console.log('Good morning!') + let stopTimeout = Scheduler.startTimeout(goodMorning, { + timeOfDay: { hour: 7, minute: 0 }, + }) + + // Later, if you want to cancel the morning greeting + stopTimeout() + ``` + + In this example, the goodMorning function will be called at 7:00 AM. If you want to cancel the morning greeting (for example, the user chose to sleep in), you can call the stopTimeout function. +   + +3. Using startInterval with a specific interval, basically a regular setInterval + + ```typescript + const sayHello = () => console.log('Hello, world!') + let stopInterval = Scheduler.startInterval(sayHello, 1000) + + // Later, if you want to stop the interval + stopInterval() + ``` + +4. Using startInterval with a specific time of day + + ```typescript + const sayHello = () => console.log('Hello, world!') + let stopInterval = Scheduler.startInterval(sayHello, 1000, { + timeOfDay: { hour: 7, minute: 0 }, + }) + + // Later, if you want to stop the interval + stopInterval() + ``` + + In this example, the sayHello function will be called every 1000 milliseconds starting at 7:00 AM. If you want to cancel the morning greeting (for example, the user chose to sleep in), you can call the stopInterval function. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..a065c62 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + moduleFileExtensions: ['js', 'mjs'], +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3379881 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "schedule-later", + "version": "1.0.0", + "main": "dist/Scheduler.js", + "author": "Nightness", + "scripts": { + "build": "tsc", + "test": "./node_modules/.bin/jest --verbose", + "watch": "./node_modules/.bin/jest --watch", + "deploy": "npm run build && npm publish" + }, + "devDependencies": { + "@types/jest": "^29.5.3", + "@types/node": "^18.11.18", + "jest": "^29.6.1", + "typescript": "^4.6.4" + } +} \ No newline at end of file diff --git a/src/Scheduler.test.ts b/src/Scheduler.test.ts new file mode 100644 index 0000000..1599c62 --- /dev/null +++ b/src/Scheduler.test.ts @@ -0,0 +1,49 @@ +import Scheduler from './Scheduler' + +describe('Scheduler', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.clearAllTimers() + }) + + test('startTimeout should correctly start and stop timeout', () => { + const callback = jest.fn() + + const stop = Scheduler.startTimeout(callback, { ms: 5000 }) + + // Advance timers by less than the delay and check if callback has not been called + jest.advanceTimersByTime(2000) + expect(callback).not.toBeCalled() + + // Advance timers to exactly the delay and check if callback has been called + jest.advanceTimersByTime(3000) + expect(callback).toBeCalled() + + // Cleanup + stop() + }) + + test('startInterval should correctly start and stop interval', () => { + const callback = jest.fn() + + const stop = Scheduler.startInterval(callback, 2000, { ms: 5000 }) + + // Advance timers by less than the initial delay and check if callback has not been called + jest.advanceTimersByTime(2000) + expect(callback).not.toBeCalled() + + // Advance timers to exactly the delay and check if callback has been called + jest.advanceTimersByTime(3000) + expect(callback).toBeCalledTimes(1) + + // Advance timers by the interval and check if callback has been called again + jest.advanceTimersByTime(2000) + expect(callback).toBeCalledTimes(2) + + // Cleanup + stop() + }) +}) diff --git a/src/Scheduler.ts b/src/Scheduler.ts new file mode 100644 index 0000000..e56caaf --- /dev/null +++ b/src/Scheduler.ts @@ -0,0 +1,166 @@ +export interface TimeOfDay { + hour: number + minute?: number + seconds?: number +} +export type TimeUntil = { + timeOfDay?: TimeOfDay + date?: Date + ms?: number +} +type StopCancelFunction = (stopRunning: boolean) => void +type StopFunction = (stopTime?: TimeUntil) => StopCancelFunction | null + +export default class Scheduler { + private static timeUntil(start: TimeUntil) { + if (start.ms) { + return start.ms + } + + // get the current date + let now = new Date() + + // set the target time + let targetTime = !start.timeOfDay + ? start.date + : new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + start.timeOfDay.hour, + start.timeOfDay.minute ?? 0, + start.timeOfDay.seconds ?? 0, + 0 + ) + + // if the target time has already passed today, set it for tomorrow + if (start.timeOfDay && now > targetTime) { + targetTime.setDate(targetTime.getDate() + 1) + } + + // calculate the delay until the next target time + // @ts-ignore - TS doesn't know this is allowed + let delay = targetTime - now + + return delay + } + + /** + * Start a timeout + * @param {Function} timerFunc + * @param {TimeUntil} start + * @return {StopFunction} + */ + public static startTimeout( + timerFunc: Function, + start: TimeUntil + ): StopFunction { + let delay = this.timeUntil(start) + + // set a timeout to start the interval at the target time + let timeout: NodeJS.Timeout = null + timeout = setTimeout(function () { + // Clear the timeout variable + timeout = null + // call the function immediately + timerFunc() + }, delay) + + const stopNow = () => { + if (timeout) clearTimeout(timeout) + } + + // stop() function to stop the interval and timeout + // stop(stopInMS) will stop the interval and timeout in stopInMS milliseconds + // stop(stopHour, stopMinute) will stop the interval and timeout at the next stopHour:stopMinute + const stop = (stopTime?: TimeUntil): StopCancelFunction => { + if (stopTime === undefined) { + stopNow() + return null + } + + if ((stopTime as any) instanceof Date) { + // @ts-ignore - TS doesn't know this is allowed + const timeFromNow = stopTime - new Date() + const stopTimeout = setTimeout(stopNow, timeFromNow) + // stopRunning is a boolean that will either cancel the stop (false), or stop the interval now (true) + return (stopRunning: boolean = false) => { + clearTimeout(stopTimeout) + if (stopRunning) stopNow() + } + } + + const stopTimeout = setTimeout(stopNow, this.timeUntil(stopTime)) + // stopRunning is a boolean that will either cancel the stop (false), or stop the interval now (true) + return (stopRunning: boolean = false) => { + clearTimeout(stopTimeout) + if (stopRunning) stopNow() + } + } + + // return a cleanup function + return stop + } + + /** + * Start an interval + * @param {Function} intervalFunc + * @param {number} intervalMS + * @param {TimeUntil} start + * @return {StopFunction} + */ + public static startInterval( + intervalFunc: Function, + intervalMS: number, + start?: TimeUntil + ): StopFunction { + let delay = this.timeUntil(start) + + // set a timeout to start the interval at the target time + let interval: number = null + let timeout = setTimeout(function () { + // Clear the timeout variable + timeout = null + // start the interval + interval = setInterval(intervalFunc, intervalMS) + // call the function immediately + intervalFunc() + }, delay) + + const stopNow = () => { + if (timeout) clearTimeout(timeout) + if (interval) clearInterval(interval) + } + + // stop() function to stop the interval and timeout + // stop(stopInMS) will stop the interval and timeout in stopInMS milliseconds + // stop(stopHour, stopMinute) will stop the interval and timeout at the next stopHour:stopMinute + const stop = (stopTime?: TimeUntil): StopCancelFunction => { + if (stopTime === undefined) { + stopNow() + return null + } + + if ((stopTime as any) instanceof Date) { + // @ts-ignore - TS doesn't know this is allowed + const timeFromNow = stopTime - new Date() + const stopTimeout = setTimeout(stopNow, timeFromNow) + // stopRunning is a boolean that will either cancel the stop (false), or stop the interval now (true) + return (stopRunning: boolean = false) => { + clearTimeout(stopTimeout) + if (stopRunning) stopNow() + } + } + + const stopTimeout = setTimeout(stopNow, this.timeUntil(stopTime)) + // stopRunning is a boolean that will either cancel the stop (false), or stop the interval now (true) + return (stopRunning: boolean = false) => { + clearTimeout(stopTimeout) + if (stopRunning) stopNow() + } + } + + // return a cleanup function + return stop + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c4b254c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,104 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, + "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "nodenext" /* Specify what module code is generated. */, + // "rootDir": "./src" /* Specify the root folder within your source files. */, + // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "rootDirs": [ + "./prisma", + "./src" + ] /* Allow multiple folders to be treated as one when resolving modules. */, + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "resolveJsonModule": true, /* Enable importing .json files */ + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": false /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}