Allow calendar end times after midnight

This commit is contained in:
callumalpass 2026-05-16 15:57:35 +10:00
parent 411b0b545b
commit fbbcda812d
7 changed files with 144 additions and 76 deletions

View file

@ -26,6 +26,7 @@ Example:
## Added
- ([#1776](https://github.com/callumalpass/tasknotes/issues/1776)) Allowed calendar timeline end times after midnight, such as `26:00` for a 2 AM next-day end. Thanks to @chrabia for suggesting this.
- ([#1784](https://github.com/callumalpass/tasknotes/issues/1784), [#1833](https://github.com/callumalpass/tasknotes/pull/1833)) Added a per-view `pinnedColumns` option for TaskNotes Kanban views, so selected empty columns can stay visible as drop targets while other empty columns remain hidden. Thanks to @loukandr for suggesting and prototyping this.
- ([#1794](https://github.com/callumalpass/tasknotes/issues/1794)) Added context updates to the task card context menu, so contexts can be added, toggled, or cleared without opening the task edit modal. Thanks to @m13ntrpn for suggesting this.
- ([#1809](https://github.com/callumalpass/tasknotes/issues/1809)) Added a subtle task-card indicator for tasks with note body details. Thanks to @aliceinwaterdeep for suggesting this.

View file

@ -15,7 +15,7 @@ Controls include:
- Default calendar view mode (month/week/day/year/custom days)
- First day of week and weekend visibility
- Locale and date formatting
- Time slot window (`slotMinTime`, `slotMaxTime`, `slotDuration`)
- Time slot window (`slotMinTime`, `slotMaxTime`, `slotDuration`). Use `slotMaxTime` values above `24:00:00`, such as `26:00:00`, to show early next-day hours in timeline views.
- Default event visibility toggles (due, scheduled, recurring, time entries, ICS)
- Event stacking and overlap display options

View file

@ -61,6 +61,7 @@ import {
CalendarRecreateNavigationState,
shouldPreserveVisibleDateOnCalendarRecreate,
} from "./calendarRecreateUtils";
import { CALENDAR_END_TIME_MAX_HOUR, normalizeCalendarTimeValue } from "../utils/calendarTime";
import type { CalendarEventData } from "../services/CalendarProvider";
type CalendarDataAdapterWithView = {
@ -447,10 +448,15 @@ export class CalendarView extends BasesViewBase {
heightMode: "fill",
customDayCount: calendarSettings.customDayCount,
listDayCount: 7,
slotMinTime: this.validateTimeValue(calendarSettings.slotMinTime, "00:00:00", false),
slotMaxTime: this.validateTimeValue(calendarSettings.slotMaxTime, "24:00:00", true),
slotDuration: this.validateTimeValue(calendarSettings.slotDuration, "00:30:00", false),
scrollTime: this.validateTimeValue(calendarSettings.scrollTime, "08:00:00", false),
slotMinTime: this.validateTimeValue(calendarSettings.slotMinTime, "00:00:00"),
slotMaxTime: this.validateTimeValue(
calendarSettings.slotMaxTime,
"24:00:00",
CALENDAR_END_TIME_MAX_HOUR,
true
),
slotDuration: this.validateTimeValue(calendarSettings.slotDuration, "00:30:00"),
scrollTime: this.validateTimeValue(calendarSettings.scrollTime, "08:00:00"),
firstDay: calendarSettings.firstDay,
weekNumbers: calendarSettings.weekNumbers,
nowIndicator: calendarSettings.nowIndicator,
@ -657,61 +663,19 @@ export class CalendarView extends BasesViewBase {
private validateTimeValue(
value: string | undefined,
defaultValue: string,
allowMax24 = false
maxHour = 23,
allowMaxHourOnlyAtZero = false
): string {
if (!value) return defaultValue;
// If already in HH:MM:SS format, validate it
if (/^\d{2}:\d{2}:\d{2}$/.test(value)) {
const [hours, minutes] = value.split(":").map(Number);
const maxHours = allowMax24 ? 24 : 23;
if (hours < 0 || hours > maxHours || minutes < 0 || minutes > 59) {
console.warn(
`[TaskNotes][CalendarView] Invalid time value: ${value}, using default: ${defaultValue}`
);
return defaultValue;
}
// Special case: 24:XX is only valid as 24:00
if (hours === 24 && minutes !== 0) {
console.warn(
`[TaskNotes][CalendarView] Invalid time value: ${value}, using default: ${defaultValue}`
);
return defaultValue;
}
return value;
const result = normalizeCalendarTimeValue(value, defaultValue, {
maxHour,
allowMaxHourOnlyAtZero,
});
if (!result.isValid) {
console.warn(
`[TaskNotes][CalendarView] Invalid time value: ${value}, using default: ${defaultValue}`
);
}
// If in HH:MM format, validate and convert to HH:MM:SS
if (/^\d{2}:\d{2}$/.test(value)) {
const [hours, minutes] = value.split(":").map(Number);
const maxHours = allowMax24 ? 24 : 23;
if (hours < 0 || hours > maxHours || minutes < 0 || minutes > 59) {
console.warn(
`[TaskNotes][CalendarView] Invalid time value: ${value}, using default: ${defaultValue}`
);
return defaultValue;
}
// Special case: 24:XX is only valid as 24:00
if (hours === 24 && minutes !== 0) {
console.warn(
`[TaskNotes][CalendarView] Invalid time value: ${value}, using default: ${defaultValue}`
);
return defaultValue;
}
return `${value}:00`;
}
// Invalid format
console.warn(
`[TaskNotes][CalendarView] Invalid time format: ${value}, using default: ${defaultValue}`
);
return defaultValue;
return result.value;
}
private getConfigOption<T>(key: string, fallback: T): T {
@ -836,23 +800,21 @@ export class CalendarView extends BasesViewBase {
// Validate time values to prevent crashes from invalid input
this.viewOptions.slotMinTime = this.validateTimeValue(
this.getConfigOption<string | undefined>("slotMinTime", undefined),
this.viewOptions.slotMinTime,
false
this.viewOptions.slotMinTime
);
this.viewOptions.slotMaxTime = this.validateTimeValue(
this.getConfigOption<string | undefined>("slotMaxTime", undefined),
this.viewOptions.slotMaxTime,
true // Allow 24:00 for end time
CALENDAR_END_TIME_MAX_HOUR,
true
);
this.viewOptions.slotDuration = this.validateTimeValue(
this.getConfigOption<string | undefined>("slotDuration", undefined),
this.viewOptions.slotDuration,
false
this.viewOptions.slotDuration
);
this.viewOptions.scrollTime = this.validateTimeValue(
this.getConfigOption<string | undefined>("scrollTime", undefined),
this.viewOptions.scrollTime,
false
this.viewOptions.scrollTime
);
this.viewOptions.firstDay = Number(

View file

@ -1403,8 +1403,9 @@ export const en: TranslationTree = {
},
endTime: {
name: "End time",
description: "Latest time shown in timeline views (HH:MM format)",
placeholder: "22:00",
description:
"Latest time shown in timeline views (HH:MM format). Use values above 24:00 to show early next-day hours, such as 26:00 for 2 AM.",
placeholder: "26:00",
},
initialScrollTime: {
name: "Initial scroll time",

View file

@ -11,6 +11,7 @@ import {
import { PropertySelectorModal } from "../../modals/PropertySelectorModal";
import { getAvailableProperties, getPropertyLabels } from "../../utils/propertyHelpers";
import type { CalendarViewSettings } from "../../types/settings";
import { CALENDAR_END_TIME_MAX_HOUR, normalizeCalendarTimeValue } from "../../utils/calendarTime";
type CalendarDefaultView = CalendarViewSettings["defaultView"];
type CalendarFirstDay = CalendarViewSettings["firstDay"];
@ -567,22 +568,21 @@ export function renderAppearanceTab(
setValue: async (value: string) => {
if (!/^\d{2}:\d{2}$/.test(value)) {
new Notice(
"Invalid time format. Please use hh:mm format (e.g., 23:00)"
"Invalid time format. Please use hh:mm format (e.g., 26:00)"
);
return;
}
const [hours, minutes] = value.split(":").map(Number);
if (hours < 0 || hours > 24 || minutes < 0 || minutes > 59) {
const result = normalizeCalendarTimeValue(value, "24:00:00", {
maxHour: CALENDAR_END_TIME_MAX_HOUR,
allowMaxHourOnlyAtZero: true,
});
if (!result.isValid) {
new Notice(
"Invalid time. Hours must be 00-24 and minutes must be 00-59"
"Invalid time. Use 00:00-48:00; values after midnight use 24:00-48:00, such as 26:00 for 2 am next day"
);
return;
}
if (hours === 24 && minutes !== 0) {
new Notice("When hour is 24, minutes must be 00");
return;
}
plugin.settings.calendarViewSettings.slotMaxTime = value + ":00";
plugin.settings.calendarViewSettings.slotMaxTime = result.value;
save();
},
})

48
src/utils/calendarTime.ts Normal file
View file

@ -0,0 +1,48 @@
export const CALENDAR_END_TIME_MAX_HOUR = 48;
export type CalendarTimeValidationOptions = {
maxHour?: number;
allowMaxHourOnlyAtZero?: boolean;
};
export type CalendarTimeValidationResult = {
value: string;
isValid: boolean;
};
export function normalizeCalendarTimeValue(
value: string | undefined,
defaultValue: string,
options: CalendarTimeValidationOptions = {}
): CalendarTimeValidationResult {
if (!value) {
return { value: defaultValue, isValid: true };
}
const match = /^(\d{2}):([0-5]\d)(?::([0-5]\d))?$/.exec(value);
if (!match) {
return { value: defaultValue, isValid: false };
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3] ?? "00");
const maxHour = options.maxHour ?? 23;
if (hours < 0 || hours > maxHour) {
return { value: defaultValue, isValid: false };
}
if (
options.allowMaxHourOnlyAtZero &&
hours === maxHour &&
(minutes !== 0 || seconds !== 0)
) {
return { value: defaultValue, isValid: false };
}
return {
value: `${match[1]}:${match[2]}:${match[3] ?? "00"}`,
isValid: true,
};
}

View file

@ -0,0 +1,56 @@
import {
CALENDAR_END_TIME_MAX_HOUR,
normalizeCalendarTimeValue,
} from "../../../src/utils/calendarTime";
describe("issue 1776 - calendar end time after midnight", () => {
it("allows timeline end times into the next day", () => {
expect(
normalizeCalendarTimeValue("26:00", "24:00:00", {
maxHour: CALENDAR_END_TIME_MAX_HOUR,
allowMaxHourOnlyAtZero: true,
})
).toEqual({ value: "26:00:00", isValid: true });
expect(
normalizeCalendarTimeValue("26:30:00", "24:00:00", {
maxHour: CALENDAR_END_TIME_MAX_HOUR,
allowMaxHourOnlyAtZero: true,
})
).toEqual({ value: "26:30:00", isValid: true });
});
it("caps extended end times to the next day boundary", () => {
expect(
normalizeCalendarTimeValue("48:00", "24:00:00", {
maxHour: CALENDAR_END_TIME_MAX_HOUR,
allowMaxHourOnlyAtZero: true,
})
).toEqual({ value: "48:00:00", isValid: true });
expect(
normalizeCalendarTimeValue("48:30", "24:00:00", {
maxHour: CALENDAR_END_TIME_MAX_HOUR,
allowMaxHourOnlyAtZero: true,
})
).toEqual({ value: "24:00:00", isValid: false });
expect(
normalizeCalendarTimeValue("49:00", "24:00:00", {
maxHour: CALENDAR_END_TIME_MAX_HOUR,
allowMaxHourOnlyAtZero: true,
})
).toEqual({ value: "24:00:00", isValid: false });
});
it("keeps ordinary calendar times inside the same day by default", () => {
expect(normalizeCalendarTimeValue("23:30", "00:00:00")).toEqual({
value: "23:30:00",
isValid: true,
});
expect(normalizeCalendarTimeValue("24:00", "00:00:00")).toEqual({
value: "00:00:00",
isValid: false,
});
});
});