Add start date in the future

Add start date in the future feature
Fix remaining logic being wrong for using ceil()
Improved formatDuration running even for unexistent tags
Improved Readme with new info and CSS customization section
Improved error message not having a container and looking weird for some
error messages
This commit is contained in:
Guilherme Cattani 2025-06-21 22:36:27 +02:00
parent d91f19eb3d
commit 874e7ce377
4 changed files with 140 additions and 51 deletions

View file

@ -35,24 +35,33 @@ Track time until important deadlines, events, or milestones with visual progress
Create a countdown progress bar by adding a code block with the `countdown-to` language identifier:
````markdown
```countdown-to
title: Project Deadline
startDate: 2025-03-12T08:00:00
endDate: 2025-04-11T14:00:00
startDate: 2025-03-12
startTime: 08:00:00
endDate: 2025-04-11
endTime: 14:00:00
type: circle
color: #ff5722
trailColor: #f5f5f5
infoFormat: {percent}% complete - {remaining} until {end:LLL d, yyyy}
updateInRealTime: true
updateInterval: 30
updateIntervalInSeconds: 30
```
````
### Required Parameters
- `endDate`: The target date to count down to (ISO format: YYYY-MM-DDTHH:MM:SS)
- `startDate`: Start date for the progress calculation, needs to be now or in the past (ISO format: YYYY-MM-DDTHH:MM:SS)
If no startDate and endDate is provided the countdown will assume the current day. So at the very least the widget requires a start time and end time. If that is the case the countdown will run daily.
- `startDate`: Start date for the progress calculation (format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)
- `startTime`: Start time for the progress calculation (format: HH:MM:SS, optional - defaults to 00:00:00)
- `endDate`: The target date to count down to (format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)
- `endTime`: The target time to count down to (format: HH:MM:SS, optional - defaults to 00:00:00)
**Note**: You can use either:
- Separate date and time parameters: `startDate: 2025-01-01` + `startTime: 09:00:00`
- Combined ISO format: `startDate: 2025-01-01T09:00:00` startTime will be ignored. This logic is the same for endDate and endTime
- The startDate or startTime can be in the future, and the info message will reflect that it is upcoming. The message for upcoming countdowns can be customized in the settings under `Default info format upcoming`.
### Optional Parameters
@ -64,7 +73,7 @@ updateInterval: 30
- `onCompleteText`: Text to display when the countdown is complete
- `infoFormat`: Custom format for the information text
- `updateInRealTime`: Whether to update the progress bar in real-time (`true`/`false`)
- `updateInterval`: How often to update the progress bar (in seconds)
- `updateIntervalInSeconds`: How often to update the progress bar
## Formatting Options
@ -107,6 +116,9 @@ You can configure default settings for all progress bars in the plugin settings:
All settings can be overridden in individual progress bar code blocks.
## CSS Customization
A custom CSS snippet can be created in your `./.obsidian/snippets` and the classes on https://github.com/guicattani/obsidian-countdown-to/blob/main/styles.css can be overriden.
## Performance Considerations
- Setting a very low update interval (e.g., 1 second) for many progress bars may impact performance

View file

@ -49,23 +49,11 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
this.containerEl.empty();
const containerEl = this.containerEl.createDiv({ cls: ['countdown-to-plugin', 'countdown-to-container'] });
const startDate = DateTime.fromISO(params.startDate);
const endDate = DateTime.fromISO(params.endDate);
if (!startDate.isValid) {
containerEl.setText('Invalid start date format. ' +
'Please use ISO + time format (YYYY-MM-DDTHH:MM:SS).');
return;
}
if (!endDate.isValid) {
containerEl.setText('Invalid end date format. ' +
'Please use ISO + time format (YYYY-MM-DDTHH:MM:SS).');
return;
}
const startDate = this.constructDateTime(params.startDate, params.startTime, 'start');
const endDate = this.constructDateTime(params.endDate, params.endTime, 'end');
if (endDate < startDate) {
containerEl.setText('End date must be after start date.');
containerEl.setText('End date/time must be after start date/time.');
return;
}
@ -127,21 +115,31 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
updateTimer: null,
});
this.updateCountdownTo(this.id, startDate, endDate);
if (startDate > DateTime.now()) {
this.updateCountdownTo(this.id, DateTime.now(), startDate, true);
} else {
this.updateCountdownTo(this.id, startDate, endDate, false);
}
const updateInRealTime = params.updateInRealTime !== undefined ?
params.updateInRealTime === 'true' :
this.plugin.settings.defaultUpdateInRealTime;
if (updateInRealTime) {
const updateInterval = params.updateInterval ?
parseInt(params.updateInterval, 10) :
if (params.updateInterval && !params.updateIntervalInSeconds) {
params.updateIntervalInSeconds = params.updateInterval;
}
const updateIntervalInSeconds = params.updateIntervalInSeconds ?
parseInt(params.updateIntervalInSeconds, 10) :
this.plugin.settings.defaultUpdateIntervalSeconds;
this.scheduleUpdate(this.id, startDate, endDate, updateInterval);
this.scheduleUpdate(this.id, startDate, endDate, updateIntervalInSeconds);
}
} catch (error) {
this.containerEl.setText('Error rendering countdown to: ' + error.message);
const containerEl = this.containerEl.createDiv({ cls: ['countdown-to-plugin', 'countdown-to-container'] });
containerEl.setText('Error rendering countdown to: ' + error.message);
}
}
@ -150,13 +148,17 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
if (!countdownTo) return;
const timer = window.setInterval(() => {
this.updateCountdownTo(id, startDate, endDate);
if (startDate > DateTime.now()) {
this.updateCountdownTo(this.id, DateTime.now(), startDate, true);
} else {
this.updateCountdownTo(this.id, startDate, endDate, false);
}
}, defaultUpdateIntervalSeconds * 1000);
countdownTo.updateTimer = timer;
}
updateCountdownTo(id: string, startDate: DateTime, endDate: DateTime) {
updateCountdownTo(id: string, startDate: DateTime, endDate: DateTime, upcoming: boolean = false) {
const countdownTo = this.plugin.countdownTos.get(id);
if (!countdownTo) return;
@ -173,6 +175,7 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
const progressType = params.progressType || this.plugin.settings.defaultProgressType;
const onCompleteText = params.onCompleteText || this.plugin.settings.defaultOnCompleteText;
const infoFormat = params.infoFormat || this.plugin.settings.defaultInfoFormat;
const infoFormatUpcoming = params.infoFormatUpcoming || this.plugin.settings.defaultInfoFormatUpcoming;
if (progressType.toLowerCase() === 'countdown') {
countdownTo.bar.set(1.0 - progress);
@ -185,7 +188,13 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
onCompleteText.replace(/{title}/g, params.title || ''),
);
} else {
let infoText = infoFormat;
let infoText;
if (upcoming) {
infoText = infoFormatUpcoming;
} else {
infoText = infoFormat;
}
const remainingInterval = Interval.fromDateTimes(currentDate, endDate);
const remainingDuration = remainingInterval.toDuration(['days', 'hours', 'minutes', 'seconds']);
@ -193,6 +202,7 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
const elapsedDuration = elapsedInterval.toDuration(['days', 'hours', 'minutes', 'seconds']);
const totalDuration = totalInterval.toDuration(['days', 'hours', 'minutes', 'seconds']);
infoText = infoText
.replace(/{start:(.*?)}/g, (_match: string, format: string) => startDate.toFormat(format))
.replace(/{end:(.*?)}/g, (_match: string, format: string) => endDate.toFormat(format))
@ -206,9 +216,17 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
.replace(/{start}/g, startDate.toISODate() || '')
.replace(/{end}/g, endDate.toISODate() || '')
.replace(/{current}/g, currentDate.toISODate() || '')
.replace(/{remaining}/g, this.formatDuration(remainingDuration))
.replace(/{elapsed}/g, this.formatDuration(elapsedDuration))
.replace(/{total}/g, this.formatDuration(totalDuration));
.replace(/{title}/g, params.title || '');
if (infoText.includes('{remaining}')) {
infoText = infoText.replace(/{remaining}/g, this.formatDuration(remainingDuration))
}
if (infoText.includes('{elapsed}')) {
infoText = infoText.replace(/{elapsed}/g, this.formatDuration(elapsedDuration))
}
if (infoText.includes('{total}')) {
infoText = infoText.replace(/{total}/g, this.formatDuration(totalDuration))
}
countdownTo.infoEl.setText(infoText);
}
@ -229,36 +247,72 @@ export class CountdownToMarkdownRenderChild extends MarkdownRenderChild {
}
});
if (!params.startDate) {
throw new Error('Start date is required');
if (!params.startDate && !params.startTime) {
throw new Error('Start date or start time is required');
}
if (DateTime.fromISO(params.startDate) > DateTime.now()) {
throw new Error('Start date must be now or in the past');
if (!params.endDate && !params.endTime) {
throw new Error('End date or end time is required');
}
if (!params.endDate) {
throw new Error('End date is required');
if (params.startTime && !params.startDate && params.endDate && !params.endTime ||
params.startTime && !params.startDate && params.endDate && params.endTime) {
throw new Error('Start time with no start date requires an end time with no end date');
}
if (params.startDate === params.endDate && params.startTime === params.endTime) {
throw new Error('Start date and end date cannot be the same');
}
return params;
}
formatDuration(duration: Duration): string {
const days = Math.ceil(duration.as('days'));
const hours = Math.ceil(duration.as('hours') % 24);
const minutes = Math.ceil(duration.as('minutes') % 60);
const seconds = Math.ceil(duration.as('seconds') % 60);
const days = Math.floor(duration.as('days'));
const hours = Math.floor(duration.as('hours') % 24);
const minutes = Math.floor(duration.as('minutes') % 60);
const seconds = Math.floor(duration.as('seconds') % 60);
if (days > 0) {
return `${days} day${days > 1 ? 's' : ''}`;
} else if (hours > 0) {
return `${hours} hour${hours > 1 ? 's' : ''}`;
return `${hours} hour${hours > 1 ? 's' : ''} ` +
`${minutes} minute${minutes > 1 ? 's' : ''} ` +
`${seconds} second${seconds > 1 ? 's' : ''}`;
} else if (minutes > 0) {
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
return `${minutes} minute${minutes > 1 ? 's' : ''} ` +
`${seconds} second${seconds > 1 ? 's' : ''}`;
} else {
return `${seconds} second${seconds > 1 ? 's' : ''}`;
}
}
constructDateTime(date: string, time: string, type: string): DateTime {
const isoDateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/;
if (isoDateTimeRegex.test(date)) {
const dateTime = DateTime.fromISO(date);
return dateTime;
}
let dateToUse = date || DateTime.now().toFormat('yyyy-MM-dd');
let timeToUse = time || '00:00:00';
if (timeToUse && !timeToUse.includes(':')) {
timeToUse = `${timeToUse}:00`;
} else if (timeToUse && timeToUse.split(':').length === 2) {
timeToUse = `${timeToUse}:00`;
}
const dateTimeString = `${dateToUse}T${timeToUse}`;
const dateTime = DateTime.fromISO(dateTimeString);
if (!dateTime.isValid) {
throw new Error(`Invalid ${type} date or time format: ${dateTimeString}`);
}
return dateTime;
}
}

View file

@ -2,14 +2,13 @@ import { Plugin } from 'obsidian';
import { CountdownToSettings, CountdownToSettingTab, DEFAULT_SETTINGS } from './settings';
import { CountdownToMarkdownRenderChild } from './countdownToMarkdownRenderChild';
// Minimal interface for progressbar.js instances
interface CountdownToJs {
interface ProgressBarJS {
set(progress: number): void;
}
interface CountdownToInstace {
interface CountdownToInstance {
element: HTMLElement;
bar: CountdownToJs;
bar: ProgressBarJS;
infoEl: HTMLElement;
params: string;
updateTimer: number | null;
@ -17,7 +16,7 @@ interface CountdownToInstace {
export default class CountdownToPlugin extends Plugin {
settings: CountdownToSettings;
countdownTos = new Map<string, CountdownToInstace>();
countdownTos = new Map<string, CountdownToInstance>();
async onload() {
await this.loadSettings();

View file

@ -9,6 +9,7 @@ export interface CountdownToSettings {
defaultProgressType: string;
defaultOnCompleteText: string;
defaultInfoFormat: string;
defaultInfoFormatUpcoming: string;
defaultUpdateInRealTime: boolean;
defaultUpdateIntervalSeconds: number;
}
@ -20,6 +21,7 @@ export const DEFAULT_SETTINGS: CountdownToSettings = {
defaultProgressType: 'Forward',
defaultOnCompleteText: '{title} is done!',
defaultInfoFormat: '{percent}% - {remaining} remaining',
defaultInfoFormatUpcoming: '{title} is coming up in {remaining}!',
defaultUpdateInRealTime: false,
defaultUpdateIntervalSeconds: 1,
};
@ -128,6 +130,28 @@ export class CountdownToSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName('Default info format upcoming')
.setDesc('Default format for the info text when the countdown is upcoming (start date in the future).')
.addTextArea(text => text
.setPlaceholder('{title} is coming up in {remaining}!')
.setValue(this.plugin.settings.defaultInfoFormatUpcoming)
.onChange(async (value) => {
this.plugin.settings.defaultInfoFormatUpcoming = value;
await this.plugin.saveSettings();
this.app.workspace.trigger(
"countdown-to:rerender"
);
}))
.addExtraButton(button => {
button
.setIcon('help')
.setTooltip('Show format help')
.onClick(() => {
new LuxonFormatHelpModal(this.plugin.app).open();
});
});
new Setting(containerEl)
.setName('Default on complete text')
.setDesc('Default text to display when the progress is complete. Use {title} to display the title of the progress bar.')