mirror of
https://github.com/mildeveloper/obsidian-date-range-expander.git
synced 2026-07-22 05:43:20 +00:00
Initial implementation of the Date Range Expander plugin for Obsidian.
This commit is contained in:
parent
f8fab7404c
commit
7f58876680
20 changed files with 8198 additions and 1 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
2
LICENSE
2
LICENSE
|
|
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
106
README.md
Normal file
106
README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Date Range Expander
|
||||
|
||||
A plugin for Obsidian that allows you to quickly insert a range of dates into your notes. Perfect for planning, journaling, or creating date-based content.
|
||||
|
||||
## Features
|
||||
|
||||
- Insert a sequence of dates using either an end date or duration
|
||||
- Format dates according to your preferences
|
||||
- Create wiki-linked dates automatically
|
||||
- Optional callout formatting for date ranges
|
||||
- Flexible file creation options for wiki-linked dates
|
||||
|
||||
## How to Use
|
||||
|
||||
1. Open the command palette (Ctrl/Cmd + P)
|
||||
2. Search for "Date Range Expander"
|
||||
3. Enter your date range details in the modal:
|
||||
- Start date (format: YYYYMMDD)
|
||||
- Choose either:
|
||||
- End date (format: YYYYMMDD), or
|
||||
- Duration (1-100 Days/Weeks/Months)
|
||||
- Toggle whether to wrap dates in a callout
|
||||
|
||||
The plugin will then insert your date range using your configured format settings.
|
||||
|
||||
## Settings
|
||||
|
||||
### Date Formatting
|
||||
|
||||
- **Output date format**: Format for the inserted dates
|
||||
- Default: YYYY.MM.DD
|
||||
- Example: 2024.03.15
|
||||
|
||||
- **Friendly date format**: Format for displaying dates in a more readable way in certains locations like the callout and alert boxes.
|
||||
- Default: DDD D MMM YYYY
|
||||
- Supports: D, DD, DDD, DDDD, M, MM, MMM, MMMM, Y, YY, YYY, YYYY
|
||||
- Example: Fri 15 Mar 2024
|
||||
|
||||
- **Date separator**: Character(s) used to separate dates in the sequence
|
||||
- Default: ", "
|
||||
- Example: 2024.03.15, 2024.03.16, 2024.03.17
|
||||
|
||||
### Wiki Links
|
||||
|
||||
- **Create wiki links**: Toggle whether dates should be inserted as wiki links
|
||||
- When enabled: [[2024.03.15]]
|
||||
- When disabled: 2024.03.15
|
||||
|
||||
### File Creation Options
|
||||
|
||||
When wiki links are enabled, you can choose how to handle non-existent date files:
|
||||
|
||||
- **Do not create**: Only create the wiki links, don't create actual files
|
||||
- **Create in same folder**: Automatically create date files in the same folder as the current note
|
||||
- **Create in custom folder**: Create date files in a specified folder
|
||||
- If selected, you can set a custom folder path (e.g., "Daily Notes")
|
||||
|
||||
## Modal Options
|
||||
|
||||
### Start Date
|
||||
- Enter the beginning date in YYYYMMDD format
|
||||
- Example: 20240315 for March 15, 2024
|
||||
|
||||
### Range Type
|
||||
Choose between two ways to specify your date range:
|
||||
|
||||
1. **End Date**
|
||||
- Enter the final date in YYYYMMDD format
|
||||
- The plugin will create a sequence from start to end date (inclusive of both dates)
|
||||
|
||||
2. **Duration**
|
||||
- Specify a number (1-100)
|
||||
- Choose the unit: Days, Weeks, or Months
|
||||
- The plugin will create a sequence starting from the start date for the specified duration
|
||||
|
||||
### Callout Option
|
||||
- Toggle "Add to callout" to wrap your date sequence in a collapsible callout
|
||||
- Useful for organizing long date sequences
|
||||
|
||||
## Examples
|
||||
|
||||
1. Simple date range:
|
||||
```
|
||||
2024.03.15, 2024.03.16, 2024.03.17
|
||||
```
|
||||
|
||||
2. Wiki-linked dates:
|
||||
```
|
||||
[[2024.03.15]], [[2024.03.16]], [[2024.03.17]]
|
||||
```
|
||||
|
||||
3. Dates in a callout:
|
||||
```
|
||||
> [!SUMMARY]- Date range: Fri 15 Mar 2024 to Sun 17 Mar 2024
|
||||
> [[2024.03.15]], [[2024.03.16]], [[2024.03.17]]
|
||||
```
|
||||
|
||||
## Say Thanks 🙏
|
||||
|
||||
If you find this plugin helpful, then maybe... toss a coin to your ~~witcher~~ developer:
|
||||
|
||||
[](https://ko-fi.com/mildeveloper)
|
||||
|
||||
[](https://buymeacoffee.com/mildeveloper)
|
||||
|
||||
Your support helps maintain and improve the plugin! 😊
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
11
jest.config.js
Normal file
11
jest.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module.exports = {
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
},
|
||||
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
moduleNameMapper: {
|
||||
'^obsidian$': '<rootDir>/tests/__mocks__/obsidian.ts'
|
||||
},
|
||||
testEnvironment: 'node'
|
||||
};
|
||||
642
main.js
Normal file
642
main.js
Normal file
File diff suppressed because one or more lines are too long
14
manifest.json
Normal file
14
manifest.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"id": "date-range-expander",
|
||||
"name": "Date Range Expander",
|
||||
"version": "0.9.0",
|
||||
"minAppVersion": "1.5.8",
|
||||
"description": "Quickly add a range of day references given a date duration.",
|
||||
"author": "Mil",
|
||||
"authorUrl": "https://github.com/mildeveloper",
|
||||
"fundingUrl": {
|
||||
"Ko-fi": "https://ko-fi.com/mildeveloper",
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com/mildeveloper"
|
||||
},
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
5772
package-lock.json
generated
Normal file
5772
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "obsidian-date-range-expander",
|
||||
"version": "0.9.0",
|
||||
"description": "Quickly add a range of day references given a date duration.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Mil",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"jest": "^29.7.0",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.2.6",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
325
src/dateInputModal.ts
Normal file
325
src/dateInputModal.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
import { DateInputModalResponse } from './types';
|
||||
import { DateUtils } from './dateUtils';
|
||||
|
||||
export class DateInputModal extends Modal {
|
||||
private startDateValue: string = '';
|
||||
private endDateValue: string = '';
|
||||
private durationValue: string = '';
|
||||
private durationTypeValue: 'Days' | 'Weeks' | 'Months' = 'Days';
|
||||
private useCalloutValue: boolean = true;
|
||||
private rangeTypeValue: 'EndDate' | 'Duration' = 'EndDate';
|
||||
|
||||
private onSubmit: (dateRangeValues: DateInputModalResponse) => void;
|
||||
private submitButton: HTMLButtonElement | null = null;
|
||||
private endDateInput: HTMLInputElement;
|
||||
private durationInput: HTMLInputElement;
|
||||
private durationTypeSelect: HTMLSelectElement;
|
||||
private dateUtils: DateUtils;
|
||||
private keyboardListener: (event: KeyboardEvent) => void;
|
||||
|
||||
constructor(app: App, onSubmit: (dateRangeValues: DateInputModalResponse) => void) {
|
||||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.dateUtils = new DateUtils();
|
||||
this.setTitle('Enter date range');
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
this.addStyles();
|
||||
this.setupKeyboardListener();
|
||||
this.createStartDateInput();
|
||||
this.createRangeOptions();
|
||||
this.createCalloutToggle();
|
||||
this.createSubmitButton();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
// Clean up the keyboard event listener
|
||||
if (this.keyboardListener) {
|
||||
contentEl.removeEventListener('keydown', this.keyboardListener);
|
||||
}
|
||||
// Clear the content
|
||||
contentEl.empty();
|
||||
}
|
||||
|
||||
private validateInput(): boolean {
|
||||
const startDateObj = this.dateUtils.parseDateFromString(this.startDateValue);
|
||||
|
||||
if (!startDateObj){
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.rangeTypeValue === 'EndDate') {
|
||||
const endDateObj = this.dateUtils.parseDateFromString(this.endDateValue);
|
||||
return !!endDateObj &&
|
||||
this.dateUtils.isValidNumericDate(this.endDateValue) &&
|
||||
endDateObj >= startDateObj;
|
||||
} else {
|
||||
return this.durationValue !== '' &&
|
||||
parseInt(this.durationValue) > 0 &&
|
||||
['Days', 'Weeks', 'Months'].includes(this.durationTypeValue);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateDateCount(): number {
|
||||
const startDate = this.dateUtils.parseDateFromString(this.startDateValue);
|
||||
if (!startDate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let endDate: Date;
|
||||
|
||||
if (this.rangeTypeValue === 'EndDate') {
|
||||
endDate = this.dateUtils.parseDateFromString(this.endDateValue) || startDate;
|
||||
} else {
|
||||
endDate = new Date(startDate);
|
||||
const duration = parseInt(this.durationValue) || 0;
|
||||
|
||||
if (this.durationTypeValue === 'Days') {
|
||||
endDate.setDate(endDate.getDate() + duration - 1); // -1 because we include the start date
|
||||
} else if (this.durationTypeValue === 'Weeks') {
|
||||
endDate.setDate(endDate.getDate() + (duration * 7) - 1);
|
||||
} else if (this.durationTypeValue === 'Months') {
|
||||
endDate = this.dateUtils.addMonthsToDate(startDate, duration);
|
||||
}
|
||||
}
|
||||
|
||||
const dateCount = this.dateUtils.calculateDaysInRange(startDate, endDate);
|
||||
return dateCount;
|
||||
}
|
||||
|
||||
private addStyles() {
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
.date-option-container {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.date-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.date-input-row input[type="radio"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.date-input-row input[type="text"],
|
||||
.date-input-row input[type="number"] {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.date-input-row select {
|
||||
margin-left: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
this.contentEl.appendChild(styleEl);
|
||||
}
|
||||
|
||||
private setupKeyboardListener() {
|
||||
this.keyboardListener = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && this.validateInput()) {
|
||||
this.submit();
|
||||
}
|
||||
};
|
||||
this.contentEl.addEventListener('keydown', this.keyboardListener);
|
||||
}
|
||||
|
||||
private createStartDateInput() {
|
||||
new Setting(this.contentEl)
|
||||
.setName('Start date')
|
||||
.addText(text => {
|
||||
text.setPlaceholder('YYYYMMDD')
|
||||
.setValue(this.startDateValue);
|
||||
|
||||
text.inputEl.setAttribute('pattern', '[0-9]*');
|
||||
text.inputEl.setAttribute('maxlength', '8');
|
||||
text.inputEl.addEventListener('input', (e: InputEvent) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
input.value = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
this.startDateValue = input.value;
|
||||
this.updateSubmitButton();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private createRangeOptions() {
|
||||
const optionContainer = this.contentEl.createEl('div', { cls: 'date-option-container' });
|
||||
this.createEndDateOption(optionContainer);
|
||||
this.createDurationOption(optionContainer);
|
||||
this.updateInputStates();
|
||||
}
|
||||
|
||||
private createEndDateOption(container: HTMLElement) {
|
||||
const endDateRow = container.createEl('div', { cls: 'date-input-row' });
|
||||
const endDateLabel = endDateRow.createEl('label');
|
||||
endDateLabel.appendText('End date');
|
||||
|
||||
const endDateRadio = endDateLabel.createEl('input', {
|
||||
type: 'radio',
|
||||
attr: { name: 'dateOption' }
|
||||
});
|
||||
endDateRadio.checked = this.rangeTypeValue === 'EndDate';
|
||||
|
||||
this.endDateInput = endDateRow.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: 'YYYYMMDD',
|
||||
value: this.endDateValue,
|
||||
attr: {
|
||||
pattern: '[0-9]*',
|
||||
maxlength: '8'
|
||||
}
|
||||
});
|
||||
|
||||
this.endDateInput.addEventListener('input', (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
input.value = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
this.endDateValue = input.value;
|
||||
this.updateSubmitButton();
|
||||
});
|
||||
|
||||
endDateRadio.addEventListener('change', () => {
|
||||
this.rangeTypeValue = 'EndDate';
|
||||
this.updateInputStates();
|
||||
this.updateSubmitButton();
|
||||
});
|
||||
}
|
||||
|
||||
private createDurationOption(container: HTMLElement) {
|
||||
const durationRow = container.createEl('div', { cls: 'date-input-row' });
|
||||
const durationLabel = durationRow.createEl('label');
|
||||
durationLabel.appendText('Duration');
|
||||
|
||||
const durationRadio = durationLabel.createEl('input', {
|
||||
type: 'radio',
|
||||
attr: { name: 'dateOption' }
|
||||
});
|
||||
durationRadio.checked = this.rangeTypeValue === 'Duration';
|
||||
|
||||
this.durationInput = durationRow.createEl('input', {
|
||||
type: 'number',
|
||||
attr: {
|
||||
min: '1',
|
||||
max: '100'
|
||||
},
|
||||
placeholder: '1-100',
|
||||
value: this.durationValue
|
||||
});
|
||||
|
||||
this.durationTypeSelect = durationRow.createEl('select');
|
||||
this.populateDurationTypes();
|
||||
|
||||
durationRadio.addEventListener('change', () => {
|
||||
this.rangeTypeValue = 'Duration';
|
||||
this.updateInputStates();
|
||||
this.updateSubmitButton();
|
||||
});
|
||||
|
||||
this.durationInput.addEventListener('input', (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
let value = parseInt(input.value);
|
||||
if (value > 100) {
|
||||
value = 100;
|
||||
input.value = '100';
|
||||
}
|
||||
this.durationValue = value.toString();
|
||||
this.updateSubmitButton();
|
||||
});
|
||||
|
||||
this.durationTypeSelect.addEventListener('change', (e) => {
|
||||
this.durationTypeValue = (e.target as HTMLSelectElement).value as 'Days' | 'Weeks' | 'Months';
|
||||
this.updateSubmitButton();
|
||||
});
|
||||
}
|
||||
|
||||
private populateDurationTypes() {
|
||||
['Days', 'Weeks', 'Months'].forEach(type => {
|
||||
const option = this.durationTypeSelect.createEl('option', {
|
||||
value: type,
|
||||
text: type
|
||||
});
|
||||
option.selected = type === this.durationTypeValue;
|
||||
});
|
||||
}
|
||||
|
||||
private updateInputStates() {
|
||||
const isEndDateMode = this.rangeTypeValue === 'EndDate';
|
||||
this.endDateInput.disabled = !isEndDateMode;
|
||||
this.durationInput.disabled = isEndDateMode;
|
||||
this.durationTypeSelect.disabled = isEndDateMode;
|
||||
}
|
||||
|
||||
private createCalloutToggle() {
|
||||
new Setting(this.contentEl)
|
||||
.setName('Add to callout')
|
||||
.setDesc('Insert dates inside a collapsed callout')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.useCalloutValue)
|
||||
.onChange(value => {
|
||||
this.useCalloutValue = value;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private createSubmitButton() {
|
||||
this.submitButton = new Setting(this.contentEl)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Insert')
|
||||
.setCta()
|
||||
.onClick(() => this.submit())
|
||||
).settingEl.querySelector('button');
|
||||
|
||||
if (this.submitButton) {
|
||||
this.submitButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private updateSubmitButton() {
|
||||
if (this.submitButton) {
|
||||
const validInput = this.validateInput();
|
||||
this.submitButton.disabled = !validInput;
|
||||
|
||||
if (validInput) {
|
||||
const dateCount = this.calculateDateCount();
|
||||
this.submitButton.textContent = `Insert ${dateCount} Date${dateCount !== 1 ? 's' : ''}`;
|
||||
} else {
|
||||
this.submitButton.textContent = 'Insert';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private submit() {
|
||||
if (!this.validateInput()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateObj = this.dateUtils.parseDateFromString(this.startDateValue);
|
||||
if (!startDateObj) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateRangeValues: DateInputModalResponse = {
|
||||
startDate: startDateObj,
|
||||
rangeType: this.rangeTypeValue,
|
||||
useCallout: this.useCalloutValue
|
||||
};
|
||||
|
||||
if (this.rangeTypeValue === 'EndDate') {
|
||||
const endDateObj = this.dateUtils.parseDateFromString(this.endDateValue);
|
||||
if (endDateObj)
|
||||
dateRangeValues.endDate = endDateObj;
|
||||
} else {
|
||||
dateRangeValues.duration = parseInt(this.durationValue);
|
||||
dateRangeValues.durationUnit = this.durationTypeValue;
|
||||
}
|
||||
|
||||
this.close();
|
||||
|
||||
if (this.onSubmit) {
|
||||
this.onSubmit(dateRangeValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
123
src/dateRangeExpander.ts
Normal file
123
src/dateRangeExpander.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { App, Notice, TFolder } from 'obsidian';
|
||||
import { PluginSettings, DateRange, DEFAULT_SETTINGS } from './types';
|
||||
import { DateUtils } from './dateUtils';
|
||||
|
||||
export class DateRangeExpander {
|
||||
app: App;
|
||||
settings: PluginSettings;
|
||||
dateUtils: DateUtils;
|
||||
|
||||
constructor(app: App, settings: PluginSettings, dateUtils: DateUtils) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.dateUtils = dateUtils;
|
||||
}
|
||||
|
||||
wrapInCallout(dateStartAndEnd: DateRange, insertedDateRange: string): string {
|
||||
const [startDateFormat, endDateFormat] = this.getFormatedStartAndEndDates(dateStartAndEnd);
|
||||
return `\n> [!SUMMARY]- Date range: ${startDateFormat} to ${endDateFormat}\n> ${insertedDateRange}\n`;
|
||||
}
|
||||
|
||||
expandDateRange(dateStartAndEnd: DateRange): string | null{
|
||||
let totalDates = 0;
|
||||
let createdFiles = 0;
|
||||
let existingFiles = 0;
|
||||
let customFolderExists = true;
|
||||
|
||||
let dates = [];
|
||||
let current = new Date(dateStartAndEnd.startDate);
|
||||
|
||||
const customFolder = this.app.vault.getAbstractFileByPath(this.settings.customFolderPath);
|
||||
|
||||
if (this.settings.createNonExistentFiles === 'custom-folder' && !(customFolder instanceof TFolder)) {
|
||||
customFolderExists = false;
|
||||
}
|
||||
|
||||
while (current <= dateStartAndEnd.endDate) {
|
||||
let formattedDate = this.dateUtils.formatDateToString(current, this.settings.outputDateFormat, DEFAULT_SETTINGS.outputDateFormat);
|
||||
|
||||
if (this.settings.createWikiLinks) {
|
||||
dates.push(`[[${formattedDate}]]`);
|
||||
|
||||
const fileCreationResult = this.createFileIfNeeded(formattedDate, customFolderExists);
|
||||
if (fileCreationResult === 'created')
|
||||
createdFiles++;
|
||||
else if (fileCreationResult === 'exists')
|
||||
existingFiles++;
|
||||
} else {
|
||||
dates.push(formattedDate);
|
||||
}
|
||||
totalDates++;
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
if (createdFiles > 0 && !customFolderExists) {
|
||||
new Notice(`Folder "${this.settings.customFolderPath}" does not exist. Using parent folder to current note instead.`);
|
||||
}
|
||||
|
||||
const [startDateFormat, endDateFormat] = this.getFormatedStartAndEndDates(dateStartAndEnd);
|
||||
|
||||
const commonNoticeText = `Inserted ${totalDates} dates from ${startDateFormat} to ${endDateFormat}`;
|
||||
if (this.settings.createNonExistentFiles !== 'do-not-create') {
|
||||
new Notice(`${commonNoticeText}\n\n• Files created: ${createdFiles}\n• Files skipped: ${existingFiles}`, 8000);
|
||||
} else {
|
||||
new Notice(commonNoticeText, 8000);
|
||||
}
|
||||
|
||||
return dates.join(this.settings.dateSeparator);
|
||||
}
|
||||
|
||||
createFileIfNeeded(formattedDate: string, customFolderExists: boolean): 'created' | 'exists' | 'failed' {
|
||||
const currentFile = this.app.workspace.getActiveFile();
|
||||
if (!currentFile || !currentFile.parent) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const fileName = `${formattedDate}.md`;
|
||||
const fileExists = this.fileExistsInVault(formattedDate);
|
||||
|
||||
if (!fileExists) {
|
||||
let filePath = '';
|
||||
|
||||
switch (this.settings.createNonExistentFiles) {
|
||||
case 'same-folder':
|
||||
const currentDir = currentFile.parent.path;
|
||||
filePath = currentDir ? `${currentDir}/${fileName}` : fileName;
|
||||
break;
|
||||
case 'custom-folder':
|
||||
if (customFolderExists) {
|
||||
filePath = `${this.settings.customFolderPath}/${fileName}`;
|
||||
} else {
|
||||
// Fallback to same folder if custom path is empty or not found
|
||||
const currentDir = currentFile.parent.path;
|
||||
filePath = currentDir ? `${currentDir}/${fileName}` : fileName;
|
||||
}
|
||||
break;
|
||||
case 'do-not-create':
|
||||
return 'failed';
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
this.app.vault.create(filePath, "");
|
||||
return 'created';
|
||||
}
|
||||
return 'failed';
|
||||
} else {
|
||||
return 'exists';
|
||||
}
|
||||
}
|
||||
|
||||
fileExistsInVault(formattedDate: string): boolean {
|
||||
return this.app.metadataCache.getFirstLinkpathDest(formattedDate, '') !== null;
|
||||
}
|
||||
|
||||
private getFormatedStartAndEndDates(dateStartAndEnd: DateRange): [string, string] {
|
||||
const startDateFormat = this.dateUtils.formatDateToString(dateStartAndEnd.startDate, this.settings.friendlyDateFormat, DEFAULT_SETTINGS.friendlyDateFormat);
|
||||
const endDateFormat = this.dateUtils.formatDateToString(dateStartAndEnd.endDate, this.settings.friendlyDateFormat, DEFAULT_SETTINGS.friendlyDateFormat);
|
||||
|
||||
return [startDateFormat, endDateFormat];
|
||||
}
|
||||
}
|
||||
148
src/dateUtils.ts
Normal file
148
src/dateUtils.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { DateInputModalResponse, DateRange } from './types';
|
||||
|
||||
export class DateUtils {
|
||||
getStartAndEndDates(modalResponse: DateInputModalResponse): DateRange | null {
|
||||
let startDate: Date = modalResponse.startDate;
|
||||
let endDate: Date;
|
||||
|
||||
if (modalResponse.rangeType === 'EndDate') {
|
||||
endDate = modalResponse.endDate!;
|
||||
} else {
|
||||
endDate = new Date(startDate);
|
||||
const duration = modalResponse.duration!;
|
||||
const unit = modalResponse.durationUnit!;
|
||||
|
||||
switch (unit) {
|
||||
case 'Days':
|
||||
endDate.setDate(startDate.getDate() + duration - 1);
|
||||
break;
|
||||
case 'Weeks':
|
||||
endDate.setDate(startDate.getDate() + (duration * 7) - 1);
|
||||
break;
|
||||
case 'Months':
|
||||
endDate = this.addMonthsToDate(startDate, duration);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startDate > endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateStartAndEnd: DateRange = {
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
};
|
||||
|
||||
return dateStartAndEnd;
|
||||
}
|
||||
|
||||
// Assuming string date is in format: YYYYMMDD
|
||||
parseDateFromString(dateString: string): Date | null {
|
||||
if (!this.isValidNumericDate(dateString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = parseInt(dateString.substring(0, 4));
|
||||
const month = parseInt(dateString.substring(4, 6)) - 1;
|
||||
const day = parseInt(dateString.substring(6, 8));
|
||||
|
||||
const date = new Date(Date.UTC(year, month, day));
|
||||
return date;
|
||||
}
|
||||
|
||||
// Returns true if string date is in format: YYYYMMDD
|
||||
isValidNumericDate(dateString: string): boolean {
|
||||
if (!/^\d{8}$/.test(dateString))
|
||||
return false;
|
||||
|
||||
const year = parseInt(dateString.substring(0, 4));
|
||||
const month = parseInt(dateString.substring(4, 6)) - 1; // JavaScript months are 0-indexed
|
||||
const day = parseInt(dateString.substring(6, 8));
|
||||
|
||||
// Additional validation
|
||||
if (year < 1900)
|
||||
return false;
|
||||
if (month < 0 || month > 11)
|
||||
return false;
|
||||
if (day < 1 || day > 31)
|
||||
return false;
|
||||
|
||||
const dateObj = new Date(year, month, day);
|
||||
|
||||
return dateObj.getFullYear() === year &&
|
||||
dateObj.getMonth() === month &&
|
||||
dateObj.getDate() === day;
|
||||
}
|
||||
|
||||
formatDateToString(date: Date, settingsFormat: string, defaultFormat: string): string {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const day = date.getDate();
|
||||
const weekday = date.getDay();
|
||||
|
||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
const dayAbbreviations = dayNames.map(day => day.slice(0, 3));
|
||||
const monthAbbreviations = monthNames.map(month => month.slice(0, 3));
|
||||
|
||||
const formatDate = (format: string): string => {
|
||||
return format.replace(/YYYY|YYY|YY|Y|MMMM|MMM|MM|M|DDDD|DDD|DD|D/g, (match) => {
|
||||
switch (match) {
|
||||
case 'YYYY': return year.toString();
|
||||
case 'YYY': return year.toString().slice(-3);
|
||||
case 'YY': return year.toString().slice(-2);
|
||||
case 'Y': return year.toString().slice(-1);
|
||||
case 'MMMM': return monthNames[month];
|
||||
case 'MMM': return monthAbbreviations[month];
|
||||
case 'MM': return (month + 1).toString().padStart(2, '0');
|
||||
case 'M': return (month + 1).toString();
|
||||
case 'DDDD': return dayNames[weekday];
|
||||
case 'DDD': return dayAbbreviations[weekday];
|
||||
case 'DD': return day.toString().padStart(2, '0');
|
||||
case 'D': return day.toString();
|
||||
default: return match;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
return formatDate(settingsFormat);
|
||||
} catch (error) {
|
||||
// Fallback to default format if anything goes wrong
|
||||
return formatDate(defaultFormat);
|
||||
}
|
||||
}
|
||||
|
||||
addMonthsToDate(date: Date, months: number): Date {
|
||||
if (months === 0) {
|
||||
return date;
|
||||
}
|
||||
|
||||
const newDate = new Date(date);
|
||||
|
||||
const currentDay = date.getDate();
|
||||
|
||||
newDate.setMonth(newDate.getMonth() + months);
|
||||
newDate.setDate(newDate.getDate() - 1);
|
||||
|
||||
// Check if the day changed (which means we went into the next month)
|
||||
if (newDate.getMonth() !== date.getMonth() && newDate.getDate() !== (currentDay - 1)) {
|
||||
// If the day changed, it means we landed on the wrong day
|
||||
// Set to the last day of the previous month
|
||||
newDate.setDate(0);
|
||||
}
|
||||
|
||||
return newDate;
|
||||
}
|
||||
|
||||
calculateDaysInRange(startDate: Date, endDate: Date): number {
|
||||
const millisecondsInDay = 1000 * 60 * 60 * 24;
|
||||
|
||||
const differenceMilliseconds = endDate.getTime() - startDate.getTime();
|
||||
const differenceDays = Math.floor(differenceMilliseconds / millisecondsInDay) + 1; // +1 to include both start and end dates
|
||||
|
||||
return Math.max(0, differenceDays);
|
||||
}
|
||||
}
|
||||
71
src/main.ts
Normal file
71
src/main.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { MarkdownView, Plugin } from 'obsidian';
|
||||
import { DateInputModal } from './dateInputModal';
|
||||
import { DEFAULT_SETTINGS, PluginSettings } from './types';
|
||||
import { DateRangeExpanderSettingTab } from './settingsTab';
|
||||
import { DateUtils } from './dateUtils';
|
||||
import { DateRangeExpander } from './dateRangeExpander';
|
||||
|
||||
export default class DateRangeExpanderPlugin extends Plugin {
|
||||
settings: PluginSettings;
|
||||
dateRangeExpander: DateRangeExpander;
|
||||
dateUtils: DateUtils;
|
||||
|
||||
async onload() {
|
||||
console.log('DateRangeExpander loaded');
|
||||
|
||||
await this.loadSettings();
|
||||
|
||||
this.dateUtils = new DateUtils();
|
||||
this.dateRangeExpander = new DateRangeExpander(this.app, this.settings, this.dateUtils);
|
||||
|
||||
this.addSettingTab(new DateRangeExpanderSettingTab(this.app, this));
|
||||
|
||||
this.addCommand({
|
||||
id: 'insert-expanded-date-range',
|
||||
name: 'Insert',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView) {
|
||||
if (!checking) {
|
||||
new DateInputModal(this.app, (rangeInput) => {
|
||||
const dateStartAndEnd = this.dateUtils.getStartAndEndDates(rangeInput);
|
||||
|
||||
if (dateStartAndEnd) {
|
||||
const editor = this.getActiveEditor();
|
||||
if (editor) {
|
||||
const cursor = editor.getCursor();
|
||||
let insertedDateRange = this.dateRangeExpander.expandDateRange(dateStartAndEnd) + ' ';
|
||||
if (rangeInput.useCallout) {
|
||||
insertedDateRange = this.dateRangeExpander.wrapInCallout(dateStartAndEnd, insertedDateRange);
|
||||
}
|
||||
editor.replaceRange(insertedDateRange, { line: cursor.line, ch: cursor.ch }, cursor);
|
||||
|
||||
const endPos = { line: cursor.line, ch: cursor.ch + insertedDateRange.length };
|
||||
editor.setCursor(endPos);
|
||||
}
|
||||
}
|
||||
}).open();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); }
|
||||
|
||||
async saveSettings() { await this.saveData(this.settings); }
|
||||
|
||||
onunload() {
|
||||
console.log('DateRangeExpander unloaded');
|
||||
}
|
||||
|
||||
getActiveEditor() {
|
||||
const activeLeaf = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeLeaf) {
|
||||
return activeLeaf.editor;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
106
src/settingsTab.ts
Normal file
106
src/settingsTab.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import DateRangeExpanderPlugin from './main';
|
||||
import { DEFAULT_SETTINGS } from './types';
|
||||
|
||||
export class DateRangeExpanderSettingTab extends PluginSettingTab {
|
||||
plugin: DateRangeExpanderPlugin;
|
||||
|
||||
constructor(app: App, plugin: DateRangeExpanderPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
this.createDateFormatSettings();
|
||||
this.createWikiLinkSettings();
|
||||
}
|
||||
|
||||
private createDateFormatSettings() {
|
||||
new Setting(this.containerEl)
|
||||
.setName('Output date format')
|
||||
.setDesc(`Format for output dates (e.g., ${DEFAULT_SETTINGS.outputDateFormat})`)
|
||||
.addText(text => text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.outputDateFormat)
|
||||
.setValue(this.plugin.settings.outputDateFormat)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.outputDateFormat = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Friendly date format')
|
||||
.setDesc(`Format for friendly date display (e.g., ${DEFAULT_SETTINGS.friendlyDateFormat}). Supports D, DD, DDD, DDDD, M, MM, MMM, MMMM, Y, YY, YYY, YYYYM.`)
|
||||
.addText(text => text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.friendlyDateFormat)
|
||||
.setValue(this.plugin.settings.friendlyDateFormat)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.friendlyDateFormat = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Date separator')
|
||||
.setDesc(`Separator between dates (e.g., "${DEFAULT_SETTINGS.dateSeparator}")`)
|
||||
.addText(text => text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.dateSeparator)
|
||||
.setValue(this.plugin.settings.dateSeparator)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.dateSeparator = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
|
||||
private createWikiLinkSettings() {
|
||||
new Setting(this.containerEl)
|
||||
.setName('Create wiki links')
|
||||
.setDesc('Create wiki links for inserted dates')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.createWikiLinks)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.createWikiLinks = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
if (this.plugin.settings.createWikiLinks) {
|
||||
this.createNonExistentFilesSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private createNonExistentFilesSettings() {
|
||||
new Setting(this.containerEl)
|
||||
.setName('Create non-existent files')
|
||||
.setDesc('What to do if a wiki linked file does not exist')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('do-not-create', 'Do not create')
|
||||
.addOption('same-folder', 'Create in same folder')
|
||||
.addOption('custom-folder', 'Create in custom folder')
|
||||
.setValue(this.plugin.settings.createNonExistentFiles)
|
||||
.onChange(async (value: 'do-not-create' | 'same-folder' | 'custom-folder') => {
|
||||
this.plugin.settings.createNonExistentFiles = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
if (this.plugin.settings.createNonExistentFiles === 'custom-folder') {
|
||||
this.createCustomFolderPathSetting();
|
||||
}
|
||||
}
|
||||
|
||||
private createCustomFolderPathSetting() {
|
||||
new Setting(this.containerEl)
|
||||
.setName('Custom folder path')
|
||||
.setDesc('Path to the folder where new files should be created (e.g., "Daily Notes/2025")')
|
||||
.addText(text => {
|
||||
text.setPlaceholder('Enter folder path')
|
||||
.setValue(this.plugin.settings.customFolderPath || '')
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customFolderPath = value;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
31
src/types.ts
Normal file
31
src/types.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export interface PluginSettings {
|
||||
outputDateFormat: string;
|
||||
friendlyDateFormat: string;
|
||||
dateSeparator: string;
|
||||
createWikiLinks: boolean;
|
||||
createNonExistentFiles: 'do-not-create' | 'same-folder' | 'custom-folder';
|
||||
customFolderPath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
outputDateFormat: 'YYYY.MM.DD',
|
||||
friendlyDateFormat: 'DDD D MMM YYYY',
|
||||
dateSeparator: ', ',
|
||||
createWikiLinks: true,
|
||||
createNonExistentFiles: 'same-folder',
|
||||
customFolderPath: ''
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export interface DateInputModalResponse {
|
||||
startDate: Date;
|
||||
rangeType: 'EndDate' | 'Duration';
|
||||
endDate?: Date;
|
||||
duration?: number;
|
||||
durationUnit?: 'Days' | 'Weeks' | 'Months';
|
||||
useCallout: boolean;
|
||||
}
|
||||
8
styles.css
Normal file
8
styles.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
18
tests/__mocks__/obsidian.ts
Normal file
18
tests/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export class App {
|
||||
vault: any;
|
||||
workspace: any;
|
||||
metadataCache: any;
|
||||
}
|
||||
|
||||
export class Notice {
|
||||
constructor(message: string, timeout?: number) {}
|
||||
}
|
||||
|
||||
export class TFolder {
|
||||
path: string;
|
||||
constructor() {
|
||||
this.path = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Add any other Obsidian classes/types you need to mock
|
||||
134
tests/dateRangeExpander.test.ts
Normal file
134
tests/dateRangeExpander.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { App, Notice, TFolder } from 'obsidian';
|
||||
import { DateRangeExpander } from '../src/dateRangeExpander';
|
||||
import { DateUtils } from '../src/dateUtils';
|
||||
import { PluginSettings, DEFAULT_SETTINGS, DateRange } from '../src/types';
|
||||
|
||||
// Mock App
|
||||
const mockApp = {
|
||||
vault: {
|
||||
getAbstractFileByPath: jest.fn(),
|
||||
create: jest.fn()
|
||||
},
|
||||
metadataCache: {
|
||||
getFirstLinkpathDest: jest.fn()
|
||||
},
|
||||
workspace: {
|
||||
getActiveFile: jest.fn()
|
||||
}
|
||||
} as unknown as App;
|
||||
|
||||
// Mock settings with default values
|
||||
const mockSettings: PluginSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
outputDateFormat: 'YYYY-MM-DD',
|
||||
dateSeparator: ', ',
|
||||
createWikiLinks: true,
|
||||
createNonExistentFiles: 'do-not-create',
|
||||
customFolderPath: 'test-folder'
|
||||
};
|
||||
|
||||
describe('DateRangeExpander', () => {
|
||||
describe('wrapInCallout', () => {
|
||||
let dateRangeExpander: DateRangeExpander;
|
||||
let dateUtils: DateUtils;
|
||||
|
||||
beforeEach(() => {
|
||||
dateUtils = new DateUtils();
|
||||
dateRangeExpander = new DateRangeExpander(mockApp, mockSettings, dateUtils);
|
||||
});
|
||||
|
||||
test('should handle single-day date ranges', () => {
|
||||
const dateRange: DateRange = {
|
||||
startDate: new Date('2024-03-15'),
|
||||
endDate: new Date('2024-03-15')
|
||||
};
|
||||
const insertedDates = '[[2024-03-15]]';
|
||||
|
||||
const result = dateRangeExpander.wrapInCallout(dateRange, insertedDates);
|
||||
|
||||
expect(result).toBe(
|
||||
'\n> [!SUMMARY]- Date range: Fri 15 Mar 2024 to Fri 15 Mar 2024\n> [[2024-03-15]]\n'
|
||||
);
|
||||
});
|
||||
|
||||
test('should wrap a simple date range in a callout', () => {
|
||||
const dateRange: DateRange = {
|
||||
startDate: new Date('2024-03-15'),
|
||||
endDate: new Date('2024-03-17')
|
||||
};
|
||||
const insertedDates = '[[2024-03-15]], [[2024-03-16]], [[2024-03-17]]';
|
||||
|
||||
const result = dateRangeExpander.wrapInCallout(dateRange, insertedDates);
|
||||
|
||||
expect(result).toBe(
|
||||
'\n> [!SUMMARY]- Date range: Fri 15 Mar 2024 to Sun 17 Mar 2024\n> [[2024-03-15]], [[2024-03-16]], [[2024-03-17]]\n'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandDateRange', () => {
|
||||
let dateRangeExpander: DateRangeExpander;
|
||||
let dateUtils: DateUtils;
|
||||
|
||||
beforeEach(() => {
|
||||
dateUtils = new DateUtils();
|
||||
dateRangeExpander = new DateRangeExpander(mockApp, mockSettings, dateUtils);
|
||||
});
|
||||
|
||||
test('should handle single-day date range', () => {
|
||||
const dateRange: DateRange = {
|
||||
startDate: new Date('2024-03-15'),
|
||||
endDate: new Date('2024-03-15')
|
||||
};
|
||||
|
||||
const result = dateRangeExpander.expandDateRange(dateRange);
|
||||
|
||||
expect(result).toBe('[[2024-03-15]]');
|
||||
});
|
||||
|
||||
test('should expand a simple date range', () => {
|
||||
const dateRange: DateRange = {
|
||||
startDate: new Date('2024-03-15'),
|
||||
endDate: new Date('2024-03-17')
|
||||
};
|
||||
|
||||
const result = dateRangeExpander.expandDateRange(dateRange);
|
||||
|
||||
expect(result).toBe('[[2024-03-15]], [[2024-03-16]], [[2024-03-17]]');
|
||||
});
|
||||
|
||||
test('should use custom date separator', () => {
|
||||
const settingsWithCustomSeparator: PluginSettings = {
|
||||
...mockSettings,
|
||||
dateSeparator: ' | '
|
||||
};
|
||||
dateRangeExpander = new DateRangeExpander(mockApp, settingsWithCustomSeparator, dateUtils);
|
||||
|
||||
const dateRange: DateRange = {
|
||||
startDate: new Date('2024-03-15'),
|
||||
endDate: new Date('2024-03-16')
|
||||
};
|
||||
|
||||
const result = dateRangeExpander.expandDateRange(dateRange);
|
||||
|
||||
expect(result).toBe('[[2024-03-15]] | [[2024-03-16]]');
|
||||
});
|
||||
|
||||
test('should use output date format', () => {
|
||||
const settingsWithCustomSeparator: PluginSettings = {
|
||||
...mockSettings,
|
||||
outputDateFormat: 'DDDD D MMMM YYYY'
|
||||
};
|
||||
dateRangeExpander = new DateRangeExpander(mockApp, settingsWithCustomSeparator, dateUtils);
|
||||
|
||||
const dateRange: DateRange = {
|
||||
startDate: new Date('2024-03-9'),
|
||||
endDate: new Date('2024-03-10')
|
||||
};
|
||||
|
||||
const result = dateRangeExpander.expandDateRange(dateRange);
|
||||
|
||||
expect(result).toBe('[[Saturday 9 March 2024]], [[Sunday 10 March 2024]]');
|
||||
});
|
||||
});
|
||||
});
|
||||
575
tests/dateUtils.test.ts
Normal file
575
tests/dateUtils.test.ts
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
import { DateUtils } from '../src/dateUtils';
|
||||
import { DateInputModalResponse } from '../src/types';
|
||||
|
||||
describe('DateUtils', () => {
|
||||
let dateUtils: DateUtils;
|
||||
|
||||
beforeEach(() => {
|
||||
dateUtils = new DateUtils();
|
||||
});
|
||||
|
||||
describe('getStartAndEndDates', () => {
|
||||
describe('EndDate range type', () => {
|
||||
test('should return correct date range when end date is after start date', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 1)), // Jan 1, 2023
|
||||
rangeType: 'EndDate',
|
||||
endDate: new Date(Date.UTC(2023, 0, 5)), // Jan 5, 2023
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 1)));
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2023, 0, 5)));
|
||||
});
|
||||
|
||||
test('should return null when end date is before start date', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 5)), // Jan 5, 2023
|
||||
rangeType: 'EndDate',
|
||||
endDate: new Date(Date.UTC(2023, 0, 1)), // Jan 1, 2023
|
||||
useCallout: false
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('should return correct date range when start and end dates are the same', () => {
|
||||
const sameDate = new Date(Date.UTC(2023, 0, 1)); // Jan 1, 2023
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: sameDate,
|
||||
rangeType: 'EndDate',
|
||||
endDate: new Date(Date.UTC(2023, 0, 1)), // Create a new Date object with the same values
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(sameDate);
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2023, 0, 1)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Duration range type with Days unit', () => {
|
||||
test('should calculate correct end date for 1 day duration', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 1)), // Jan 1, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 1,
|
||||
durationUnit: 'Days',
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 1)));
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2023, 0, 1))); // Same day (1 day duration - 1)
|
||||
});
|
||||
|
||||
test('should calculate correct end date for 7 days duration', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 1)), // Jan 1, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 7,
|
||||
durationUnit: 'Days',
|
||||
useCallout: false
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 1)));
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2023, 0, 7))); // Jan 7, 2023 (7 days duration - 1)
|
||||
});
|
||||
|
||||
test('should handle month boundary correctly with days duration', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 30)), // Jan 30, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 5,
|
||||
durationUnit: 'Days',
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 30)));
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2023, 1, 3))); // Feb 3, 2023 (5 days duration - 1)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Duration range type with Weeks unit', () => {
|
||||
test('should calculate correct end date for 1 week duration', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 1)), // Jan 1, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 1,
|
||||
durationUnit: 'Weeks',
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 1)));
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2023, 0, 7))); // Jan 7, 2023 (7 days)
|
||||
});
|
||||
|
||||
test('should calculate correct end date for 2 weeks duration', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 1)), // Jan 1, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 2,
|
||||
durationUnit: 'Weeks',
|
||||
useCallout: false
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 1)));
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2023, 0, 14))); // Jan 14, 2023 (14 days)
|
||||
});
|
||||
|
||||
test('should handle month and year boundaries with weeks duration', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 11, 26)), // Dec 26, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 2,
|
||||
durationUnit: 'Weeks',
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 11, 26)));
|
||||
expect(result?.endDate).toEqual(new Date(Date.UTC(2024, 0, 8))); // Jan 8, 2024 (14 days later - 1)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Duration range type with Months unit', () => {
|
||||
test('should calculate correct end date for 1 month duration', () => {
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 15)), // Jan 15, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 1,
|
||||
durationUnit: 'Months',
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 15)));
|
||||
expect(result?.endDate.getFullYear()).toBe(2023);
|
||||
expect(result?.endDate.getMonth()).toBe(1); // February (0-indexed)
|
||||
expect(result?.endDate.getDate()).toBe(14);
|
||||
});
|
||||
|
||||
test('should handle month with different days correctly', () => {
|
||||
// Testing with Jan 31 + 1 month should result in Feb 28 (in 2023, non-leap year)
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2023, 0, 31)), // Jan 31, 2023
|
||||
rangeType: 'Duration',
|
||||
duration: 1,
|
||||
durationUnit: 'Months',
|
||||
useCallout: false
|
||||
};
|
||||
|
||||
const expectedResult = new Date(2023, 1, 28); // Feb 28, 2023
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2023, 0, 31)));
|
||||
expect(result?.endDate).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
test('should handle leap year edge case correctly', () => {
|
||||
// Testing Feb 29, 2024 (leap year) + 1 month
|
||||
const modalResponse: DateInputModalResponse = {
|
||||
startDate: new Date(Date.UTC(2024, 1, 29)), // Feb 29, 2024
|
||||
rangeType: 'Duration',
|
||||
duration: 1,
|
||||
durationUnit: 'Months',
|
||||
useCallout: true
|
||||
};
|
||||
|
||||
// Assuming addMonthsToDate handles this correctly
|
||||
const expectedResult = new Date(2024, 2, 28); // Mar 28, 2024
|
||||
|
||||
const result = dateUtils.getStartAndEndDates(modalResponse);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.startDate).toEqual(new Date(Date.UTC(2024, 1, 29)));
|
||||
expect(result?.endDate).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDateFromString', () => {
|
||||
test.each([
|
||||
// [description, input, expected]
|
||||
['parses a standard date', '20240101', new Date(Date.UTC(2024, 0, 1))],
|
||||
['parses a date with single-digit month and day', '20240501', new Date(Date.UTC(2024, 4, 1))],
|
||||
['parses a date at month boundary', '20241231', new Date(Date.UTC(2024, 11, 31))],
|
||||
['parses a leap year date', '20240229', new Date(Date.UTC(2024, 1, 29))],
|
||||
])('%s', (_, input, expected) => {
|
||||
const result = dateUtils.parseDateFromString(input);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
// Edge cases where isValidNumericDate would return true but we want to test parsing logic
|
||||
['handles January correctly (month 1 → index 0)', '20240101', 0],
|
||||
['handles December correctly (month 12 → index 11)', '20241201', 11],
|
||||
])('%s', (_, input, expectedMonth) => {
|
||||
const result = dateUtils.parseDateFromString(input);
|
||||
expect(result?.getMonth()).toBe(expectedMonth);
|
||||
});
|
||||
|
||||
// Test that years, months, and days are parsed correctly
|
||||
test('parses the date components correctly', () => {
|
||||
const result = dateUtils.parseDateFromString('20241015');
|
||||
|
||||
expect(result?.getFullYear()).toBe(2024);
|
||||
expect(result?.getMonth()).toBe(9); // October is month 9 in JS
|
||||
expect(result?.getDate()).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidNumericDate', () => {
|
||||
// Valid dates tests
|
||||
test.each([
|
||||
['20240307', 'Current date'],
|
||||
['19991231', 'Last day of 1999'],
|
||||
['20000101', 'First day of 2000'],
|
||||
['20240229', 'Leap year day in 2024'],
|
||||
['19000101', 'Year 1900'],
|
||||
['49991231', 'Far future date']
|
||||
])('should return true for valid date %s (%s)', (input, _description) => {
|
||||
expect(dateUtils.isValidNumericDate(input)).toBe(true);
|
||||
});
|
||||
|
||||
// Invalid format tests
|
||||
test.each([
|
||||
['', 'Empty string'],
|
||||
['2024-03-07', 'ISO format with hyphens'],
|
||||
['03/07/2024', 'MM/DD/YYYY format'],
|
||||
['202403', 'Too few digits'],
|
||||
['202403071', 'Too many digits'],
|
||||
['2024030', 'Incomplete date'],
|
||||
['YYYYMMDD', 'Non-numeric characters'],
|
||||
['2024/03/07', 'With slashes'],
|
||||
['20240a07', 'With letter'],
|
||||
[' 20240307', 'With leading space'],
|
||||
['20240307 ', 'With trailing space']
|
||||
])('should return false for invalid format %s (%s)', (input, _description) => {
|
||||
expect(dateUtils.isValidNumericDate(input)).toBe(false);
|
||||
});
|
||||
|
||||
// Invalid year tests
|
||||
test.each([
|
||||
['18991231', 'Year < 1900'],
|
||||
])('should return false for invalid year in %s (%s)', (input, _description) => {
|
||||
expect(dateUtils.isValidNumericDate(input)).toBe(false);
|
||||
});
|
||||
|
||||
// Invalid month tests
|
||||
test.each([
|
||||
['20240001', 'Month = 00'],
|
||||
['20241301', 'Month = 13'],
|
||||
['20249901', 'Month = 99']
|
||||
])('should return false for invalid month in %s (%s)', (input, _description) => {
|
||||
expect(dateUtils.isValidNumericDate(input)).toBe(false);
|
||||
});
|
||||
|
||||
// Invalid day tests
|
||||
test.each([
|
||||
['20240100', 'Day = 00'],
|
||||
['20240132', 'Day = 32'],
|
||||
['20240199', 'Day = 99'],
|
||||
['20240230', 'February 30 in leap year'],
|
||||
['20230229', 'February 29 in non-leap year'],
|
||||
['20240431', 'Day 31 in 30-day month (April)'],
|
||||
['20240631', 'Day 31 in 30-day month (June)'],
|
||||
['20240931', 'Day 31 in 30-day month (September)'],
|
||||
['20241131', 'Day 31 in 30-day month (November)'],
|
||||
['21000229', 'February 29 in centuary leap year but not quad-centuary (2100'],
|
||||
['22000229', 'February 29 in centuary leap year but not quad-centuary (2200'],
|
||||
['23000229', 'February 29 in centuary leap year but not quad-centuary (2300']
|
||||
])('should return false for invalid day in %s (%s)', (input, _description) => {
|
||||
expect(dateUtils.isValidNumericDate(input)).toBe(false);
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
test.each([
|
||||
['19000101', 'Minimum valid year'],
|
||||
['50001231', 'Maximum valid year'],
|
||||
['20240101', 'First day of month'],
|
||||
['20240131', 'Last day of 31-day month'],
|
||||
['20240229', 'Last day of February in leap year 2024'],
|
||||
['20250228', 'Last day of February in non-leap year 2025'],
|
||||
['20000229', 'Last day of February in century leap year 2000']
|
||||
])('should properly validate edge case %s (%s)', (input, _description) => {
|
||||
expect(dateUtils.isValidNumericDate(input)).toBe(true);
|
||||
});
|
||||
|
||||
// Specific edge case for year 1900 (not a leap year despite being divisible by 4)
|
||||
test('should correctly handle century year 1900 (not a leap year)', () => {
|
||||
expect(dateUtils.isValidNumericDate('19000229')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateToString', () => {
|
||||
// Standard use cases
|
||||
describe('standard formats', () => {
|
||||
test('should format date using YYYY-MM-DD pattern', () => {
|
||||
const date = new Date(Date.UTC(2023, 0, 15)); // January 15, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'YYYY-MM-DD', 'M/D/YYYY');
|
||||
expect(result).toBe('2023-01-15');
|
||||
});
|
||||
|
||||
test('should format date using MMM D, YYYY pattern', () => {
|
||||
const date = new Date(Date.UTC(2023, 2, 10)); // March 10, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'MMM D, YYYY', 'M/D/YYYY');
|
||||
expect(result).toBe('Mar 10, 2023');
|
||||
});
|
||||
|
||||
test('should format date with full month and day names', () => {
|
||||
const date = new Date(Date.UTC(2023, 6, 4)); // July 4, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'DDDD, MMMM D, YYYY', 'M/D/YYYY');
|
||||
expect(result).toBe('Tuesday, July 4, 2023');
|
||||
});
|
||||
});
|
||||
|
||||
// Testing individual format tokens
|
||||
describe('format tokens', () => {
|
||||
const testDate = new Date(Date.UTC(2023, 11, 5)); // December 5, 2023 (Tuesday)
|
||||
|
||||
test.each([
|
||||
['YYYY', '2023'],
|
||||
['YYY', '023'],
|
||||
['YY', '23'],
|
||||
['Y', '3'],
|
||||
['MMMM', 'December'],
|
||||
['MMM', 'Dec'],
|
||||
['MM', '12'],
|
||||
['M', '12'],
|
||||
['DDDD', 'Tuesday'],
|
||||
['DDD', 'Tue'],
|
||||
['DD', '05'],
|
||||
['D', '5']
|
||||
])('should correctly format %s token', (format, expected) => {
|
||||
const result = dateUtils.formatDateToString(testDate, format, 'M/D/YYYY');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
describe('edge cases', () => {
|
||||
test('should handle single-digit day and month', () => {
|
||||
const date = new Date(Date.UTC(2023, 0, 1)); // January 1, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'M/D/YYYY', 'MM/DD/YYYY');
|
||||
expect(result).toBe('1/1/2023');
|
||||
});
|
||||
|
||||
test('should handle last day of the year', () => {
|
||||
const date = new Date(Date.UTC(2023, 11, 31)); // December 31, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'YYYY-MM-DD', 'M/D/YYYY');
|
||||
expect(result).toBe('2023-12-31');
|
||||
});
|
||||
|
||||
test('should handle February 29 on leap year', () => {
|
||||
const date = new Date(Date.UTC(2024, 1, 29)); // February 29, 2024 (leap year)
|
||||
const result = dateUtils.formatDateToString(date, 'MMMM D, YYYY', 'M/D/YYYY');
|
||||
expect(result).toBe('February 29, 2024');
|
||||
});
|
||||
|
||||
test('should handle pattern with repeated tokens', () => {
|
||||
const date = new Date(Date.UTC(2023, 4, 15)); // May 15, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'YYYY-MM-DD (DDDD)', 'M/D/YYYY');
|
||||
expect(result).toBe('2023-05-15 (Monday)');
|
||||
});
|
||||
});
|
||||
|
||||
// Date boundary tests
|
||||
describe('date boundaries', () => {
|
||||
test('should handle dates at year boundaries', () => {
|
||||
// New Year's Eve
|
||||
let date = new Date(Date.UTC(2023, 11, 31, 23, 59, 59));
|
||||
let result = dateUtils.formatDateToString(date, 'YYYY-MM-DD', 'M/D/YYYY');
|
||||
expect(result).toBe('2023-12-31');
|
||||
|
||||
// New Year's Day
|
||||
date = new Date(Date.UTC(2024, 0, 1, 0, 0, 1));
|
||||
result = dateUtils.formatDateToString(date, 'YYYY-MM-DD', 'M/D/YYYY');
|
||||
expect(result).toBe('2024-01-01');
|
||||
});
|
||||
|
||||
test('should handle historical dates', () => {
|
||||
const date = new Date(Date.UTC(1900, 0, 1)); // January 1, 1900
|
||||
const result = dateUtils.formatDateToString(date, 'MMMM D, YYYY', 'M/D/YYYY');
|
||||
expect(result).toBe('January 1, 1900');
|
||||
});
|
||||
|
||||
test('should handle future dates', () => {
|
||||
const date = new Date(Date.UTC(2123, 5, 15)); // June 15, 2123
|
||||
const result = dateUtils.formatDateToString(date, 'MMMM D, YYYY', 'M/D/YYYY');
|
||||
expect(result).toBe('June 15, 2123');
|
||||
});
|
||||
});
|
||||
|
||||
// Patterns with non-token text
|
||||
describe('patterns with mixed content', () => {
|
||||
test('should preserve non-token text in format', () => {
|
||||
const date = new Date(Date.UTC(2023, 6, 4)); // July 4, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'Created on MMMM D, YYYY at noon', 'M/D/YYYY');
|
||||
expect(result).toBe('Created on July 4, 2023 at noon');
|
||||
});
|
||||
|
||||
test('should handle formats with special characters', () => {
|
||||
const date = new Date(Date.UTC(2023, 8, 15)); // September 15, 2023
|
||||
const result = dateUtils.formatDateToString(date, 'YYYY.MM.DD - (DDD)', 'M/D/YYYY');
|
||||
expect(result).toBe('2023.09.15 - (Fri)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('day of week calculations', () => {
|
||||
test('should correctly determine weekday names', () => {
|
||||
// Test all days of a week
|
||||
const weekdays = [
|
||||
new Date(Date.UTC(2023, 9, 1)), // Sunday, October 1, 2023
|
||||
new Date(Date.UTC(2023, 9, 2)), // Monday, October 2, 2023
|
||||
new Date(Date.UTC(2023, 9, 3)), // Tuesday, October 3, 2023
|
||||
new Date(Date.UTC(2023, 9, 4)), // Wednesday, October 4, 2023
|
||||
new Date(Date.UTC(2023, 9, 5)), // Thursday, October 5, 2023
|
||||
new Date(Date.UTC(2023, 9, 6)), // Friday, October 6, 2023
|
||||
new Date(Date.UTC(2023, 9, 7)) // Saturday, October 7, 2023
|
||||
];
|
||||
|
||||
const expectedNames = [
|
||||
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
|
||||
];
|
||||
|
||||
weekdays.forEach((date, index) => {
|
||||
const result = dateUtils.formatDateToString(date, 'DDDD', 'M/D/YYYY');
|
||||
expect(result).toBe(expectedNames[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMonthsToDate', () => {
|
||||
// Format: [startDate, monthsToAdd, expectedDate]
|
||||
// Using strings in ISO format: 'YYYY-MM-DD'
|
||||
const testCases = [
|
||||
// Basic cases
|
||||
['2024-01-15', 1, '2024-02-14'], // Regular month addition
|
||||
['2024-07-02', 1, '2024-08-01'], // Regular month addition
|
||||
['2024-04-10', 3, '2024-07-09'], // Multiple months
|
||||
['2024-03-05', 12, '2025-03-04'], // Year boundary
|
||||
|
||||
// In same month
|
||||
['2024-07-01', 1, '2024-07-31'], // In same month
|
||||
['2024-06-01', 1, '2024-06-30'], // In same month
|
||||
['2024-02-01', 1, '2024-02-29'], // In same month
|
||||
['2025-02-01', 1, '2025-02-28'], // In same month
|
||||
|
||||
// Month-end cases
|
||||
['2024-01-31', 1, '2024-02-29'], // To leap day
|
||||
['2024-01-30', 1, '2024-02-29'], // To leap day
|
||||
['2023-01-31', 1, '2023-02-28'], // To non-leap Feb
|
||||
['2023-01-30', 1, '2023-02-28'], // To non-leap Feb
|
||||
['2023-01-29', 1, '2023-02-28'], // To non-leap Feb
|
||||
['2024-02-29', 1, '2024-03-28'], // From leap day
|
||||
['2024-01-31', 2, '2024-03-30'], // 31 month to 31 month
|
||||
['2024-01-31', 3, '2024-04-30'], // 31 month to 30 month (January to April)
|
||||
['2024-03-31', 1, '2024-04-30'], // 31 month to 30 month (March to April)
|
||||
|
||||
// Other scenarios
|
||||
['2024-12-15', 1, '2025-01-14'], // Year crossover
|
||||
['2024-07-04', 24, '2026-07-03'], // Multi-year
|
||||
['2024-08-15', 0, '2024-08-15'], // Zero months
|
||||
['2024-02-29', 12, '2025-02-28'], // Leap to non-leap year
|
||||
['2023-02-28', 12, '2024-02-27'], // Non-leap to leap year
|
||||
['2020-02-29', 48, '2024-02-28'], // Leap year to leap year
|
||||
['2021-02-28', 48, '2025-02-27'], // Non-leap year to non-leap year
|
||||
['2024-01-30', 1, '2024-02-29'] // Day preservation in leap
|
||||
];
|
||||
|
||||
testCases.forEach(([startStr, months, expectedStr]) => {
|
||||
it(`should add ${months} month(s) to ${startStr} and get ${expectedStr}`, () => {
|
||||
// Arrange
|
||||
const startDate = new Date(startStr);
|
||||
const expectedDate = new Date(expectedStr);
|
||||
|
||||
// Act
|
||||
const resultDate = dateUtils.addMonthsToDate(startDate, months as number);
|
||||
|
||||
// Assert
|
||||
expect(resultDate.getFullYear()).toBe(expectedDate.getFullYear());
|
||||
expect(resultDate.getMonth()).toBe(expectedDate.getMonth());
|
||||
expect(resultDate.getDate()).toBe(expectedDate.getDate());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDaysInRange', () => {
|
||||
// Normal cases
|
||||
test('returns correct number of days for same date', () => {
|
||||
const date = new Date('2023-05-15');
|
||||
expect(dateUtils.calculateDaysInRange(date, date)).toBe(1);
|
||||
});
|
||||
|
||||
test('returns correct number of days for consecutive dates', () => {
|
||||
const startDate = new Date('2023-05-15');
|
||||
const endDate = new Date('2023-05-16');
|
||||
expect(dateUtils.calculateDaysInRange(startDate, endDate)).toBe(2);
|
||||
});
|
||||
|
||||
test('returns correct number of days for a week period', () => {
|
||||
const startDate = new Date('2023-05-15');
|
||||
const endDate = new Date('2023-05-21');
|
||||
expect(dateUtils.calculateDaysInRange(startDate, endDate)).toBe(7);
|
||||
});
|
||||
|
||||
test('returns correct number of days for a month period', () => {
|
||||
const startDate = new Date('2023-05-01');
|
||||
const endDate = new Date('2023-05-31');
|
||||
expect(dateUtils.calculateDaysInRange(startDate, endDate)).toBe(31);
|
||||
});
|
||||
|
||||
test('handles leap year correctly', () => {
|
||||
const startDate = new Date('2024-02-28');
|
||||
const endDate = new Date('2024-03-01');
|
||||
expect(dateUtils.calculateDaysInRange(startDate, endDate)).toBe(3); // Feb 28, 29, Mar 1
|
||||
});
|
||||
|
||||
test('handles month boundaries correctly', () => {
|
||||
const startDate = new Date('2023-05-31');
|
||||
const endDate = new Date('2023-06-02');
|
||||
expect(dateUtils.calculateDaysInRange(startDate, endDate)).toBe(3);
|
||||
});
|
||||
|
||||
test('handles year boundaries correctly', () => {
|
||||
const startDate = new Date('2023-12-30');
|
||||
const endDate = new Date('2024-01-02');
|
||||
expect(dateUtils.calculateDaysInRange(startDate, endDate)).toBe(4);
|
||||
});
|
||||
|
||||
test('handles dates far apart', () => {
|
||||
const startDate = new Date('2000-01-01');
|
||||
const endDate = new Date('2023-12-31');
|
||||
// 8766 days including both start and end dates
|
||||
expect(dateUtils.calculateDaysInRange(startDate, endDate)).toBe(8766);
|
||||
});
|
||||
});
|
||||
});
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue