From fbbcda812d94609fa798ebaa6df068a2f88f69c5 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 16 May 2026 15:57:35 +1000 Subject: [PATCH] Allow calendar end times after midnight --- docs/releases/unreleased.md | 1 + docs/settings/calendar-settings.md | 2 +- src/bases/CalendarView.ts | 90 ++++++------------- src/i18n/resources/en.ts | 5 +- src/settings/tabs/appearanceTab.ts | 18 ++-- src/utils/calendarTime.ts | 48 ++++++++++ ...e-1776-calendar-end-after-midnight.test.ts | 56 ++++++++++++ 7 files changed, 144 insertions(+), 76 deletions(-) create mode 100644 src/utils/calendarTime.ts create mode 100644 tests/unit/issues/issue-1776-calendar-end-after-midnight.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 76d6a179..9b3ab857 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -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. diff --git a/docs/settings/calendar-settings.md b/docs/settings/calendar-settings.md index 98e6f2b1..a680aae8 100644 --- a/docs/settings/calendar-settings.md +++ b/docs/settings/calendar-settings.md @@ -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 diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index c07b9157..30a1e8c7 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -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(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("slotMinTime", undefined), - this.viewOptions.slotMinTime, - false + this.viewOptions.slotMinTime ); this.viewOptions.slotMaxTime = this.validateTimeValue( this.getConfigOption("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("slotDuration", undefined), - this.viewOptions.slotDuration, - false + this.viewOptions.slotDuration ); this.viewOptions.scrollTime = this.validateTimeValue( this.getConfigOption("scrollTime", undefined), - this.viewOptions.scrollTime, - false + this.viewOptions.scrollTime ); this.viewOptions.firstDay = Number( diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index f341c1d8..8477715e 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -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", diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index a38e79c8..e02adbfb 100644 --- a/src/settings/tabs/appearanceTab.ts +++ b/src/settings/tabs/appearanceTab.ts @@ -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(); }, }) diff --git a/src/utils/calendarTime.ts b/src/utils/calendarTime.ts new file mode 100644 index 00000000..02e8e635 --- /dev/null +++ b/src/utils/calendarTime.ts @@ -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, + }; +} diff --git a/tests/unit/issues/issue-1776-calendar-end-after-midnight.test.ts b/tests/unit/issues/issue-1776-calendar-end-after-midnight.test.ts new file mode 100644 index 00000000..241fba52 --- /dev/null +++ b/tests/unit/issues/issue-1776-calendar-end-after-midnight.test.ts @@ -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, + }); + }); +});