mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Allow calendar end times after midnight
This commit is contained in:
parent
411b0b545b
commit
fbbcda812d
7 changed files with 144 additions and 76 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
48
src/utils/calendarTime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue