Initial implementation of the Date Range Expander plugin for Obsidian.

This commit is contained in:
Milan 2025-03-18 21:21:33 +00:00
parent f8fab7404c
commit 7f58876680
20 changed files with 8198 additions and 1 deletions

10
.editorconfig Normal file
View 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

106
README.md Normal file
View 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:
[![Ko-Fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/mildeveloper)
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/mildeveloper)
Your support helps maintain and improve the plugin! 😊

49
esbuild.config.mjs Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

14
manifest.json Normal file
View 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

File diff suppressed because it is too large Load diff

29
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.
*/

View 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

View 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
View 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
View 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"
]
}