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

View file

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