emberleif_Chronoboard/main.js
2026-06-12 17:06:56 -05:00

2439 lines
85 KiB
JavaScript

const {
ItemView,
Menu,
Modal,
Notice,
Plugin,
PluginSettingTab,
Setting,
TFile,
moment,
} = require("obsidian");
const VIEW_TYPE = "chronoboard-view";
const PX_PER_HOUR = 56;
const STATUS_SORT_ORDER = {
"in progress": 0,
"in-progress": 0,
ongoing: 1,
"on hold": 2,
"on-hold": 2,
upcoming: 3
};
const DEFAULT_SETTINGS = {
folder: "Chronoboard Tasks",
timeEntryNotesFolder: "Chronoboard Time Entry Notes",
selectedTaskPaths: [],
staticTaskPaths: [],
boardOnlyTaskPaths: [],
hideWeekends: true,
visibleStartHour: 8,
visibleEndHour: 19,
activeProperty: "active",
jiraTag: "chronoboard",
taskTag: "task",
filterProperty: "Status",
excludedValues: ["finished"],
colorProperty: "timeboardColor",
subtitleProperty: "timeboardSubtitle",
highlightColor: "",
forceDarkTextOnColored: false
};
function normalizeStatus(value) {
return String(value || "").trim().toLowerCase();
}
function getStatusSortRank(value) {
const normalized = normalizeStatus(value);
return Object.prototype.hasOwnProperty.call(STATUS_SORT_ORDER, normalized)
? STATUS_SORT_ORDER[normalized]
: 99;
}
function normalizeStatusList(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeStatus(item)).filter(Boolean);
}
const normalized = normalizeStatus(value);
return normalized ? [normalized] : [];
}
function extractStatusValuesFromText(text, statusProperty) {
const lines = String(text || "").split(/\r?\n/);
const values = [];
const propertyPattern = new RegExp(`^${statusProperty}:\\s*(.*)$`, "i");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const match = line.match(propertyPattern);
if (!match) {
continue;
}
const inlineValue = match[1].trim();
if (inlineValue) {
values.push(...normalizeStatusList(inlineValue));
}
for (let j = i + 1; j < lines.length; j += 1) {
const next = lines[j];
if (!/^\s+-\s+/.test(next)) {
break;
}
values.push(normalizeStatus(next.replace(/^\s+-\s+/, "")));
}
break;
}
return values.filter(Boolean);
}
function rawFrontmatterContainsExcludedValue(text, propertyName, excludedValues) {
const lines = String(text || "").split(/\r?\n/);
const propertyPattern = new RegExp(`^${propertyName}:\\s*(.*)$`, "i");
const excluded = new Set((excludedValues || []).map((value) => normalizeStatus(value)).filter(Boolean));
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const match = line.match(propertyPattern);
if (!match) {
continue;
}
const inlineValue = match[1].trim();
if (excluded.has(normalizeStatus(inlineValue))) {
return true;
}
for (let j = i + 1; j < lines.length; j += 1) {
const next = lines[j];
if (!/^\s+-\s+/.test(next)) {
break;
}
if (excluded.has(normalizeStatus(next.replace(/^\s+-\s+/, "")))) {
return true;
}
}
return false;
}
return false;
}
function parseFrontmatterDate(value) {
if (!value) {
return null;
}
const parsed = moment(value);
return parsed.isValid() ? parsed : null;
}
function clampNumber(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function formatHours(minutes) {
const hours = minutes / 60;
return `${hours % 1 === 0 ? hours.toFixed(0) : hours.toFixed(1)}h`;
}
function parsePathList(value) {
return String(value || "")
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
function getHoursColor(minutes) {
if (minutes >= 480) {
return "is-strong";
}
if (minutes >= 240) {
return "is-medium";
}
if (minutes > 0) {
return "is-light";
}
return "";
}
function normalizeHexColor(value) {
const raw = String(value || "").trim();
if (!raw) {
return "";
}
const withHash = raw.startsWith("#") ? raw : `#${raw}`;
return /^#[0-9a-fA-F]{6}$/.test(withHash) ? withHash : "";
}
function getHighlightCssValue(value) {
return normalizeHexColor(value) || "var(--interactive-accent)";
}
function getStatusToneClass(value) {
const normalized = normalizeStatus(value);
if (normalized === "in progress" || normalized === "in-progress") {
return "is-in-progress";
}
if (normalized === "ongoing") {
return "is-ongoing";
}
if (normalized === "on hold" || normalized === "on-hold") {
return "is-on-hold";
}
if (normalized === "upcoming") {
return "is-upcoming";
}
return "";
}
function getContrastTextColor(hexColor) {
const hex = normalizeHexColor(hexColor).replace("#", "");
if (!hex) {
return "#ffffff";
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.62 ? "#111111" : "#ffffff";
}
function sanitizeFileSegment(value) {
return String(value || "")
.replace(/[\\/:*?"<>|]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function createEntryId() {
return `chrono-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
class TaskPickerModal extends Modal {
constructor(app, plugin, options) {
super(app);
this.plugin = plugin;
this.onChoose = options.onChoose;
this.excludePaths = new Set(options.excludePaths || []);
}
async onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText("Add task");
contentEl.empty();
const candidates = await this.plugin.getAvailableTasks();
const filtered = candidates.filter((task) => !this.excludePaths.has(task.path));
if (!filtered.length) {
contentEl.createDiv({
cls: "chronoboard-modal-empty",
text: "No eligible notes are available in the configured folder."
});
return;
}
const listEl = contentEl.createDiv({ cls: "chronoboard-modal-list" });
filtered.forEach((task) => {
const card = listEl.createDiv({ cls: "chronoboard-modal-card" });
card.createDiv({ cls: "chronoboard-task-key", text: task.jiraKey || task.file.basename });
const secondaryText = this.plugin.getTaskSecondaryText(task);
if (secondaryText) {
card.createDiv({ cls: "chronoboard-task-name", text: secondaryText });
}
card.createDiv({
cls: "chronoboard-task-meta",
text: `${task.status || "unknown"}${task.active ? " • active" : ""}`
});
card.addEventListener("click", () => {
this.onChoose(task);
this.close();
});
});
}
onClose() {
this.contentEl.empty();
}
}
class PreciseTimeEditModal extends Modal {
constructor(app, options) {
super(app);
this.entry = options.entry;
this.onSave = options.onSave;
this.dayKey = moment(options.entry.startTime).format("YYYY-MM-DD");
this.startValue = moment(options.entry.startTime).format("HH:mm");
this.endValue = moment(options.entry.endTime).format("HH:mm");
}
onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText("Precise Edit Time");
contentEl.empty();
contentEl.createDiv({
cls: "chronoboard-modal-empty",
text: `Editing ${this.dayKey}`
});
new Setting(contentEl)
.setName("Start time")
.addText((text) => {
text.setPlaceholder("08:00").setValue(this.startValue);
text.inputEl.type = "time";
text.onChange((value) => {
this.startValue = value;
});
});
new Setting(contentEl)
.setName("End time")
.addText((text) => {
text.setPlaceholder("09:30").setValue(this.endValue);
text.inputEl.type = "time";
text.onChange((value) => {
this.endValue = value;
});
});
const actions = contentEl.createDiv({ cls: "chronoboard-modal-actions" });
const saveButton = actions.createEl("button", { text: "Save" });
const cancelButton = actions.createEl("button", { text: "Cancel" });
saveButton.addEventListener("click", async () => {
if (!this.startValue || !this.endValue) {
new Notice("Start and end time are required.");
return;
}
const start = moment(`${this.dayKey}T${this.startValue}`);
const end = moment(`${this.dayKey}T${this.endValue}`);
if (!start.isValid() || !end.isValid() || !end.isAfter(start)) {
new Notice("End time must be after start time.");
return;
}
await this.onSave({
startTime: start.format("YYYY-MM-DDTHH:mm"),
endTime: end.format("YYYY-MM-DDTHH:mm")
});
this.close();
});
cancelButton.addEventListener("click", () => this.close());
}
onClose() {
this.contentEl.empty();
}
}
class TicketColorModal extends Modal {
constructor(app, options) {
super(app);
this.initialColor = normalizeHexColor(options.initialColor) || "#4f8ad9";
this.onSave = options.onSave;
this.onClear = options.onClear;
}
onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText("Change ticket color");
contentEl.empty();
let currentColor = this.initialColor;
new Setting(contentEl)
.setName("Color")
.setDesc("Set the color used for this ticket in the timeboard.")
.addText((text) => {
text.setPlaceholder("#4f8ad9").setValue(currentColor);
text.onChange((value) => {
currentColor = value;
});
})
.addExtraButton((button) => {
button.setIcon("palette");
button.onClick(() => {});
});
const pickerWrap = contentEl.createDiv({ cls: "chronoboard-color-picker-wrap" });
const picker = pickerWrap.createEl("input");
picker.type = "color";
picker.value = currentColor;
picker.addEventListener("input", () => {
currentColor = picker.value;
});
const actions = contentEl.createDiv({ cls: "chronoboard-modal-actions" });
const saveButton = actions.createEl("button", { text: "Save" });
const clearButton = actions.createEl("button", { text: "Clear" });
const cancelButton = actions.createEl("button", { text: "Cancel" });
saveButton.addEventListener("click", async () => {
const normalized = normalizeHexColor(currentColor);
if (!normalized) {
new Notice("Enter a valid hex color like #4f8ad9.");
return;
}
await this.onSave(normalized);
this.close();
});
clearButton.addEventListener("click", async () => {
await this.onClear();
this.close();
});
cancelButton.addEventListener("click", () => this.close());
}
onClose() {
this.contentEl.empty();
}
}
class TimeBoxTextModal extends Modal {
constructor(app, options) {
super(app);
this.initialText = String(options.initialText || "");
this.onSave = options.onSave;
}
onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText("Edit box text");
contentEl.empty();
let currentValue = this.initialText;
new Setting(contentEl)
.setName("Box notes")
.setDesc("This text appears below the time inside the time box.")
.addText((text) => {
text.setPlaceholder("What you did in this block").setValue(currentValue);
text.onChange((value) => {
currentValue = value;
});
});
const actions = contentEl.createDiv({ cls: "chronoboard-modal-actions" });
const saveButton = actions.createEl("button", { text: "Save" });
const clearButton = actions.createEl("button", { text: "Clear" });
const cancelButton = actions.createEl("button", { text: "Cancel" });
saveButton.addEventListener("click", async () => {
await this.onSave(currentValue.trim());
this.close();
});
clearButton.addEventListener("click", async () => {
await this.onSave("");
this.close();
});
cancelButton.addEventListener("click", () => this.close());
}
onClose() {
this.contentEl.empty();
}
}
class TicketSubtitleModal extends Modal {
constructor(app, options) {
super(app);
this.initialText = String(options.initialText || "");
this.onSave = options.onSave;
}
onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText("Edit ticket subtitle");
contentEl.empty();
let currentValue = this.initialText;
new Setting(contentEl)
.setName("Subtitle")
.setDesc("Shown below the ticket key on cards and above the time inside blocks.")
.addText((text) => {
text.setPlaceholder("Meeting name or short label").setValue(currentValue);
text.onChange((value) => {
currentValue = value;
});
});
const actions = contentEl.createDiv({ cls: "chronoboard-modal-actions" });
const saveButton = actions.createEl("button", { text: "Save" });
const clearButton = actions.createEl("button", { text: "Clear" });
const cancelButton = actions.createEl("button", { text: "Cancel" });
saveButton.addEventListener("click", async () => {
await this.onSave(currentValue.trim());
this.close();
});
clearButton.addEventListener("click", async () => {
await this.onSave("");
this.close();
});
cancelButton.addEventListener("click", () => this.close());
}
onClose() {
this.contentEl.empty();
}
}
class ChronoboardSettingTab extends PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Folder")
.setDesc("Folder containing the notes used by the add-task picker.")
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.folder)
.setValue(this.plugin.settings.folder)
.onChange(async (value) => {
this.plugin.settings.folder = value.trim() || DEFAULT_SETTINGS.folder;
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
new Setting(containerEl)
.setName("Metadata property")
.setDesc("Property name used when deciding what to exclude from the add-task menu.")
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.filterProperty)
.setValue(this.plugin.settings.filterProperty)
.onChange(async (value) => {
this.plugin.settings.filterProperty = value.trim() || DEFAULT_SETTINGS.filterProperty;
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
new Setting(containerEl)
.setName("Excluded values")
.setDesc("Comma-separated values that exclude a note from the add-task menu when found in the metadata property.")
.addText((text) =>
text
.setPlaceholder("Finished")
.setValue((this.plugin.settings.excludedValues || []).join(", "))
.onChange(async (value) => {
this.plugin.settings.excludedValues = value
.split(",")
.map((item) => normalizeStatus(item))
.filter(Boolean);
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
new Setting(containerEl)
.setName("Always include notes")
.setDesc("Note paths that should always appear on the task list and totals rail. One path per line or comma-separated.")
.addTextArea((text) =>
text
.setPlaceholder("Chronoboard Tasks/Meetings.md")
.setValue((this.plugin.settings.staticTaskPaths || []).join("\n"))
.onChange(async (value) => {
this.plugin.settings.staticTaskPaths = parsePathList(value);
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
new Setting(containerEl)
.setName("Hide weekends in week view")
.setDesc("Show only Monday through Friday in the week timeline.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.hideWeekends)
.onChange(async (value) => {
this.plugin.settings.hideWeekends = value;
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
new Setting(containerEl)
.setName("Visible start hour")
.setDesc("First hour marker shown in week and day views.")
.addSlider((slider) =>
slider
.setLimits(0, 20, 1)
.setDynamicTooltip()
.setValue(this.plugin.settings.visibleStartHour)
.onChange(async (value) => {
this.plugin.settings.visibleStartHour = value;
if (this.plugin.settings.visibleEndHour <= value) {
this.plugin.settings.visibleEndHour = value + 1;
}
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
new Setting(containerEl)
.setName("Visible end hour")
.setDesc("Last hour marker shown in week and day views.")
.addSlider((slider) =>
slider
.setLimits(1, 24, 1)
.setDynamicTooltip()
.setValue(this.plugin.settings.visibleEndHour)
.onChange(async (value) => {
this.plugin.settings.visibleEndHour = Math.max(value, this.plugin.settings.visibleStartHour + 1);
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
new Setting(containerEl)
.setName("Highlight color")
.setDesc("Accent color used for selected states and active controls.")
.addText((text) => {
const getPickerValue = () =>
normalizeHexColor(this.plugin.settings.highlightColor)
|| normalizeHexColor(getComputedStyle(document.body).getPropertyValue("--interactive-accent"))
|| "#8b5cf6";
text
.setPlaceholder("Obsidian default")
.setValue(this.plugin.settings.highlightColor)
.onChange(async (value) => {
this.plugin.settings.highlightColor = normalizeHexColor(value);
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
});
const control = text.inputEl.closest(".setting-item-control") || text.inputEl.parentElement;
const pickerWrap = control.createDiv({ cls: "chronoboard-setting-color-wrap" });
const picker = pickerWrap.createEl("input", { cls: "chronoboard-setting-color-picker" });
picker.type = "color";
picker.value = getPickerValue();
picker.addEventListener("pointerdown", (event) => event.stopPropagation());
picker.addEventListener("click", (event) => event.stopPropagation());
picker.addEventListener("input", async () => {
this.plugin.settings.highlightColor = picker.value;
text.setValue(picker.value);
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
});
})
.addExtraButton((button) => {
button.setIcon("reset");
button.setTooltip("Reset to Obsidian default");
button.onClick(async () => {
this.plugin.settings.highlightColor = DEFAULT_SETTINGS.highlightColor;
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
this.display();
});
});
new Setting(containerEl)
.setName("Force dark text on colored cards")
.setDesc("Use dark text on colored task cards and time blocks even when the color is light.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.forceDarkTextOnColored)
.onChange(async (value) => {
this.plugin.settings.forceDarkTextOnColored = value;
await this.plugin.saveSettings();
await this.plugin.refreshAllViews();
})
);
}
}
class ChronoboardView extends ItemView {
constructor(leaf, plugin) {
super(leaf);
this.plugin = plugin;
this.focusDate = moment();
this.currentMode = "week";
this.selectedTaskPath = plugin.settings.selectedTaskPaths[0] || null;
this.leftPanelCollapsed = false;
this.rightPanelCollapsed = false;
this.dragState = null;
this.pendingMoveHold = null;
this.activeContextMenu = null;
this.boundMouseMove = (event) => this.handleMouseMove(event);
this.boundMouseUp = () => this.handleMouseUp();
}
getViewType() {
return VIEW_TYPE;
}
getDisplayText() {
return "Chronoboard";
}
getIcon() {
return "blocks";
}
async onOpen() {
this.contentEl.addClass("chronoboard-view");
await this.render();
}
async onClose() {
this.cancelPendingMoveHold();
this.closeActiveContextMenu();
this.removeDragListeners();
}
async refresh() {
await this.render();
}
getVisibleHourCount() {
return this.plugin.settings.visibleEndHour - this.plugin.settings.visibleStartHour;
}
getTimelineHeight() {
return this.getVisibleHourCount() * (this.currentHourHeight || PX_PER_HOUR);
}
getSelectedPaths() {
const deduped = [];
const seen = new Set();
const combinedPaths = [
...(this.plugin.settings.staticTaskPaths || []),
...(this.plugin.settings.selectedTaskPaths || [])
];
for (const path of combinedPaths) {
if (path && !seen.has(path)) {
seen.add(path);
deduped.push(path);
}
}
return deduped;
}
getBoardPaths() {
const deduped = [];
const seen = new Set();
const combinedPaths = [
...(this.plugin.settings.staticTaskPaths || []),
...(this.plugin.settings.selectedTaskPaths || []),
...(this.plugin.settings.boardOnlyTaskPaths || [])
];
for (const path of combinedPaths) {
if (path && !seen.has(path)) {
seen.add(path);
deduped.push(path);
}
}
return deduped;
}
isStaticTask(path) {
return (this.plugin.settings.staticTaskPaths || []).includes(path);
}
shouldDisplayTaskStatus(task) {
return Boolean(task?.status) && !this.isStaticTask(task.path);
}
getWeekDays() {
const start = this.focusDate.clone().startOf("isoWeek");
const days = [];
const endIndex = this.plugin.settings.hideWeekends ? 5 : 7;
for (let i = 0; i < endIndex; i += 1) {
days.push(start.clone().add(i, "days"));
}
return days;
}
getMonthDays(monthStart) {
const calendarStart = monthStart.clone().startOf("isoWeek");
const calendarEnd = monthStart.clone().endOf("month").endOf("isoWeek");
const days = [];
const cursor = calendarStart.clone();
while (cursor.isSameOrBefore(calendarEnd, "day")) {
const isoDay = cursor.isoWeekday();
if (!this.plugin.settings.hideWeekends || isoDay <= 5) {
days.push(cursor.clone());
}
cursor.add(1, "day");
}
return days;
}
sortTasks(tasks) {
return [...tasks].sort((a, b) => {
const rankDiff = getStatusSortRank(a.status) - getStatusSortRank(b.status);
if (rankDiff !== 0) {
return rankDiff;
}
return a.displayTitle.localeCompare(b.displayTitle);
});
}
getScopedTasks(tasks, options = {}) {
const scoped = tasks.filter((task) => this.getSummaryMinutesForTask(task) > 0);
if (options.includeSelectedIfMissing && this.selectedTaskPath) {
const selectedTask = tasks.find((task) => task.path === this.selectedTaskPath);
if (selectedTask && !scoped.find((task) => task.path === selectedTask.path)) {
scoped.push(selectedTask);
}
}
return this.sortTasks(scoped);
}
async render() {
this.contentEl.empty();
this.contentEl.addClass("chronoboard-view");
this.contentEl.style.setProperty("--chronoboard-highlight", getHighlightCssValue(this.plugin.settings.highlightColor));
const poolPaths = this.getSelectedPaths();
const boardPaths = this.getBoardPaths();
const taskMap = await this.plugin.getTaskMap(boardPaths);
const currentTasks = this.sortTasks(poolPaths.map((path) => taskMap.get(path)).filter(Boolean));
const visibleTasks = this.sortTasks(boardPaths.map((path) => taskMap.get(path)).filter(Boolean));
if (!this.selectedTaskPath || !visibleTasks.find((task) => task.path === this.selectedTaskPath)) {
this.selectedTaskPath = currentTasks[0]?.path || visibleTasks[0]?.path || null;
}
this.renderToolbar(currentTasks);
const layoutClasses = ["chronoboard-layout"];
if (this.leftPanelCollapsed) {
layoutClasses.push("is-left-collapsed");
}
if (this.rightPanelCollapsed) {
layoutClasses.push("is-right-collapsed");
}
const layout = this.contentEl.createDiv({ cls: layoutClasses.join(" ") });
this.renderTaskRail(layout, currentTasks, poolPaths);
this.renderCenter(layout, visibleTasks);
this.renderSummary(layout, visibleTasks);
}
getDynamicHourHeight(panel) {
const availableHeight = Math.max((panel.clientHeight || this.contentEl.clientHeight || 0) - 82, 0);
const stretched = availableHeight > 0 ? availableHeight / this.getVisibleHourCount() : PX_PER_HOUR;
return Math.max(PX_PER_HOUR, Math.floor(stretched));
}
getDayTimelineWidth(container) {
const width = container.clientWidth || container.getBoundingClientRect().width || 0;
return Math.max(width, this.getVisibleHourCount() * 84);
}
renderToolbar(selectedTasks) {
const toolbar = this.contentEl.createDiv({ cls: "chronoboard-toolbar" });
const left = toolbar.createDiv({ cls: "chronoboard-toolbar-group" });
left.createDiv({ cls: "chronoboard-title", text: "Chronoboard" });
left.createDiv({
cls: "chronoboard-subtle",
text: `${selectedTasks.length} selected task${selectedTasks.length === 1 ? "" : "s"}`
});
const right = toolbar.createDiv({ cls: "chronoboard-toolbar-group" });
const statsButton = right.createEl("button", {
cls: `chronoboard-stats-button${this.currentMode === "stats" ? " is-active" : ""}`,
text: "Stats"
});
statsButton.addEventListener("click", async () => {
this.currentMode = "stats";
await this.render();
});
const taskToggle = right.createEl("button", {
cls: "chronoboard-collapse-button",
text: this.leftPanelCollapsed ? "Task" : "Hide Task"
});
taskToggle.addEventListener("click", async () => {
this.leftPanelCollapsed = !this.leftPanelCollapsed;
await this.render();
});
const totalsToggle = right.createEl("button", {
cls: "chronoboard-collapse-button",
text: this.rightPanelCollapsed ? "Totals" : "Hide Totals"
});
totalsToggle.addEventListener("click", async () => {
this.rightPanelCollapsed = !this.rightPanelCollapsed;
await this.render();
});
const prevButton = right.createEl("button", { cls: "chronoboard-nav-button", text: "<" });
prevButton.addEventListener("click", async () => {
this.focusDate = this.focusDate.clone().subtract(1, this.currentMode === "day" ? "day" : this.currentMode === "month" || this.currentMode === "stats" ? "month" : "week");
await this.render();
});
right.createDiv({ cls: "chronoboard-pill", text: this.getDateLabel() });
const nextButton = right.createEl("button", { cls: "chronoboard-nav-button", text: ">" });
nextButton.addEventListener("click", async () => {
this.focusDate = this.focusDate.clone().add(1, this.currentMode === "day" ? "day" : this.currentMode === "month" || this.currentMode === "stats" ? "month" : "week");
await this.render();
});
const todayButton = right.createDiv({ cls: "chronoboard-pill", text: "Today" });
todayButton.addEventListener("click", async () => {
this.currentMode = "day";
this.focusDate = moment();
await this.render();
});
const dayPill = right.createDiv({
cls: `chronoboard-pill${this.currentMode === "day" ? " is-active" : ""}`,
text: "Day"
});
dayPill.addEventListener("click", async () => {
this.currentMode = "day";
await this.render();
});
const weekPill = right.createDiv({
cls: `chronoboard-pill${this.currentMode === "week" ? " is-active" : ""}`,
text: "Week"
});
weekPill.addEventListener("click", async () => {
this.currentMode = "week";
await this.render();
});
const monthPill = right.createDiv({
cls: `chronoboard-pill${this.currentMode === "month" ? " is-active" : ""}`,
text: "Month"
});
monthPill.addEventListener("click", async () => {
this.currentMode = "month";
await this.render();
});
}
getDateLabel() {
if (this.currentMode === "day") {
return this.focusDate.format("MMM D, YYYY");
}
if (this.currentMode === "week") {
const start = this.focusDate.clone().startOf("isoWeek");
return `${start.format("MMM D")} to ${start.clone().add(6, "days").format("MMM D")}`;
}
if (this.currentMode === "stats") {
return this.focusDate.format("MMMM YYYY");
}
return this.focusDate.format("MMMM YYYY");
}
renderTaskRail(layout, selectedTasks, selectedPaths) {
const panel = layout.createDiv({ cls: "chronoboard-panel" });
panel.createDiv({ cls: "chronoboard-panel-head", text: "Task" });
if (this.leftPanelCollapsed) {
return;
}
const list = panel.createDiv({ cls: "chronoboard-task-list" });
selectedTasks.forEach((task) => {
const card = list.createDiv({
cls: `chronoboard-task-card${task.path === this.selectedTaskPath ? " is-selected" : ""}${this.taskHasVisibleTime(task) ? " has-time" : ""}`
});
this.applyTicketSurfaceStyle(card, task, this.taskHasVisibleTime(task));
const main = card.createDiv({ cls: "chronoboard-task-main" });
main.createDiv({ cls: "chronoboard-task-key", text: task.jiraKey || task.file.basename });
const secondaryText = this.plugin.getTaskSecondaryText(task);
if (secondaryText) {
main.createDiv({ cls: "chronoboard-task-name", text: secondaryText });
}
if (this.shouldDisplayTaskStatus(task)) {
main.createDiv({ cls: `chronoboard-task-meta chronoboard-status ${getStatusToneClass(task.status)}`, text: task.status });
}
card.addEventListener("click", async () => {
this.selectedTaskPath = task.path;
await this.render();
});
card.addEventListener("contextmenu", (event) => this.openTicketContextMenu(event, task));
});
const add = list.createDiv({ cls: "chronoboard-add-slot" });
add.createDiv({ cls: "chronoboard-plus", text: "+" });
add.createSpan({ text: "Add task slot" });
add.addEventListener("click", () => {
new TaskPickerModal(this.app, this.plugin, {
excludePaths: selectedPaths,
onChoose: async (task) => {
this.plugin.settings.selectedTaskPaths = [...this.plugin.settings.selectedTaskPaths, task.path];
this.plugin.settings.boardOnlyTaskPaths = (this.plugin.settings.boardOnlyTaskPaths || []).filter((path) => path !== task.path);
this.selectedTaskPath = task.path;
await this.plugin.saveSettings();
await this.render();
}
}).open();
});
if (!selectedTasks.length) {
list.createDiv({
cls: "chronoboard-empty",
text: "Use the plus button to add notes from the configured folder."
});
}
}
renderCenter(layout, selectedTasks) {
const panel = layout.createDiv({ cls: "chronoboard-panel chronoboard-center" });
const head = panel.createDiv({ cls: "chronoboard-panel-head chronoboard-center-head" });
if (this.currentMode === "day") {
head.createDiv({ cls: "chronoboard-center-title chronoboard-center-title-left", text: "Daily Time" });
head.createDiv({ cls: "chronoboard-center-day-label", text: this.focusDate.format("dddd") });
head.createDiv({ cls: "chronoboard-center-spacer" });
} else {
head.createDiv({
cls: "chronoboard-center-title",
text:
this.currentMode === "week"
? "Weekly Time"
: this.currentMode === "month"
? "Monthly Time"
: "Statistics"
});
}
if (this.currentMode === "week") {
this.renderWeekTimeline(panel, selectedTasks);
return;
}
if (this.currentMode === "day") {
this.renderDayTimeline(panel, this.getScopedTasks(selectedTasks, { includeSelectedIfMissing: true }));
return;
}
if (this.currentMode === "month") {
this.renderMonthTimeline(panel, selectedTasks);
return;
}
this.renderStatistics(panel, selectedTasks);
}
renderWeekTimeline(panel, selectedTasks) {
const selectedTask = selectedTasks.find((task) => task.path === this.selectedTaskPath);
const weekDays = this.getWeekDays();
this.currentHourHeight = this.getDynamicHourHeight(panel);
const wrapper = panel.createDiv({ cls: "chronoboard-week-layout" });
wrapper.style.setProperty("--chronoboard-hour-height", `${this.currentHourHeight}px`);
wrapper.style.setProperty("--chronoboard-grid-bottom", `${this.getTimelineHeight()}px`);
wrapper.createDiv({ cls: "chronoboard-week-corner" });
const dayHead = wrapper.createDiv({ cls: "chronoboard-week-head" });
dayHead.style.gridTemplateColumns = `repeat(${weekDays.length}, minmax(0, 1fr))`;
for (const dayMoment of weekDays) {
const day = dayHead.createDiv({ cls: "chronoboard-day" });
const headRow = day.createDiv({ cls: "chronoboard-day-head-row" });
const left = headRow.createDiv({ cls: "chronoboard-day-head-left" });
left.createSpan({ cls: "chronoboard-day-name", text: dayMoment.format("ddd") });
left.createSpan({ cls: "chronoboard-day-date", text: dayMoment.format("MMM D") });
headRow.createSpan({
cls: `chronoboard-day-total ${getHoursColor(selectedTasks.reduce((sum, task) => sum + this.getMinutesForDay(task.timeEntries || [], dayMoment.format("YYYY-MM-DD")), 0))}`,
text: formatHours(selectedTasks.reduce((sum, task) => sum + this.getMinutesForDay(task.timeEntries || [], dayMoment.format("YYYY-MM-DD")), 0))
});
}
const hours = wrapper.createDiv({ cls: "chronoboard-week-hours" });
const grid = wrapper.createDiv({ cls: "chronoboard-week-grid" });
grid.style.height = `${this.getTimelineHeight()}px`;
grid.style.gridTemplateColumns = `repeat(${weekDays.length}, minmax(0, 1fr))`;
this.renderHourMarkers(hours, false, this.currentHourHeight);
for (const dayMoment of weekDays) {
const dayKey = dayMoment.format("YYYY-MM-DD");
const column = grid.createDiv({ cls: "chronoboard-week-day-column" });
column.style.height = `${this.getTimelineHeight()}px`;
column.addEventListener("mousedown", (event) => this.handleWeekMouseDown(event, selectedTask, dayMoment, column));
if (this.dragState && this.dragState.action !== "resize" && this.dragState.mode === "week" && this.dragState.dayKey === dayKey) {
column.addClass("is-dragging");
this.renderDraftBlock(column, this.dragState, false);
}
selectedTasks.forEach((task) => {
(task.timeEntries || []).forEach((entry, index) => {
const start = parseFrontmatterDate(entry.startTime);
if (!start || start.format("YYYY-MM-DD") !== dayKey) {
return;
}
this.renderEntryBlock(column, entry, task, index, false);
});
});
}
}
renderDayTimeline(panel, selectedTasks) {
const selectedDay = this.focusDate.clone().startOf("day");
this.currentHourHeight = this.getDynamicHourHeight(panel);
const wrapper = panel.createDiv({ cls: "chronoboard-day-layout" });
wrapper.style.setProperty("--chronoboard-hour-height", `${this.currentHourHeight}px`);
wrapper.createDiv({ cls: "chronoboard-day-left-head", text: "Task" });
const hoursHead = wrapper.createDiv({ cls: "chronoboard-day-hours-head" });
hoursHead.style.setProperty("--chronoboard-hour-count", String(this.getVisibleHourCount()));
const timelineWidth = Math.max((panel.getBoundingClientRect().width || 0) - 221, this.getVisibleHourCount() * 84);
const dayHourWidth = timelineWidth / this.getVisibleHourCount();
hoursHead.style.width = `${timelineWidth}px`;
hoursHead.style.setProperty("--chronoboard-day-hour-width", `${dayHourWidth}px`);
this.renderHourMarkers(hoursHead, true, null, dayHourWidth);
const taskColumn = wrapper.createDiv({ cls: "chronoboard-day-task-column" });
const gridColumn = wrapper.createDiv({ cls: "chronoboard-day-grid-column" });
if (!selectedTasks.length) {
gridColumn.createDiv({
cls: "chronoboard-empty",
text: "Add a task on the left to start logging time."
});
return;
}
selectedTasks.forEach((task) => {
const taskCard = taskColumn.createDiv({
cls: `chronoboard-day-task-card${task.path === this.selectedTaskPath ? " is-selected" : ""}${this.taskHasVisibleTime(task) ? " has-time" : ""}`
});
taskCard.createDiv({ cls: "chronoboard-task-key", text: task.jiraKey || task.file.basename });
const secondaryText = this.plugin.getTaskSecondaryText(task);
if (secondaryText) {
taskCard.createDiv({ cls: "chronoboard-task-name", text: secondaryText });
}
if (this.shouldDisplayTaskStatus(task)) {
taskCard.createDiv({ cls: `chronoboard-task-meta chronoboard-status ${getStatusToneClass(task.status)}`, text: task.status });
}
this.applyTicketSurfaceStyle(taskCard, task, this.taskHasVisibleTime(task));
taskCard.addEventListener("click", async () => {
this.selectedTaskPath = task.path;
await this.render();
});
taskCard.addEventListener("contextmenu", (event) => this.openTicketContextMenu(event, task));
const row = gridColumn.createDiv({ cls: "chronoboard-day-row" });
row.style.width = `${timelineWidth}px`;
row.style.setProperty("--chronoboard-day-hour-width", `${dayHourWidth}px`);
row.addEventListener("mousedown", (event) => this.handleDayMouseDown(event, task, selectedDay, row));
if (this.dragState && this.dragState.action !== "resize" && this.dragState.mode === "day" && this.dragState.taskPath === task.path) {
row.addClass("is-dragging");
this.renderDraftBlock(row, this.dragState, true);
}
(task.timeEntries || []).forEach((entry, index) => {
const start = parseFrontmatterDate(entry.startTime);
if (!start || start.format("YYYY-MM-DD") !== selectedDay.format("YYYY-MM-DD")) {
return;
}
this.renderEntryBlock(row, entry, task, index, true);
});
});
}
renderMonthTimeline(panel, selectedTasks) {
const monthStart = this.focusDate.clone().startOf("month");
const monthDays = this.getMonthDays(monthStart);
const wrapper = panel.createDiv({ cls: "chronoboard-month-layout" });
const head = wrapper.createDiv({ cls: "chronoboard-month-head" });
const monthLabels = this.plugin.settings.hideWeekends
? ["Mon", "Tue", "Wed", "Thu", "Fri"]
: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
head.style.gridTemplateColumns = `repeat(${monthLabels.length}, minmax(0, 1fr))`;
monthLabels.forEach((label) => {
head.createDiv({ cls: "chronoboard-month-head-cell", text: label });
});
const grid = wrapper.createDiv({ cls: "chronoboard-month-grid" });
grid.style.gridTemplateColumns = `repeat(${monthLabels.length}, minmax(0, 1fr))`;
for (const dayMoment of monthDays) {
const dayKey = dayMoment.format("YYYY-MM-DD");
const isCurrentMonth = dayMoment.month() === monthStart.month();
const cell = grid.createDiv({
cls: `chronoboard-month-cell${isCurrentMonth ? "" : " is-outside"}${dayMoment.isSame(moment(), "day") ? " is-today" : ""}`
});
const cellHead = cell.createDiv({ cls: "chronoboard-month-cell-head" });
cellHead.createDiv({
cls: "chronoboard-month-date",
text: dayMoment.format("D")
});
const dayEntries = [];
selectedTasks.forEach((task) => {
(task.timeEntries || []).forEach((entry) => {
const start = parseFrontmatterDate(entry.startTime);
if (!start || start.format("YYYY-MM-DD") !== dayKey) {
return;
}
dayEntries.push({ task, entry });
});
});
const totalMinutes = dayEntries.reduce((sum, item) => sum + this.entryMinutes(item.entry), 0);
cellHead.createDiv({
cls: `chronoboard-month-total ${getHoursColor(totalMinutes)}`,
text: totalMinutes ? formatHours(totalMinutes) : "0h"
});
const groupedEntries = this.groupMonthEntries(dayEntries);
const layoutCount = Math.min(groupedEntries.length, 5);
const list = cell.createDiv({
cls: `chronoboard-month-entry-list count-${layoutCount || 0}`
});
const visibleEntries = groupedEntries.length >= 5 ? groupedEntries.slice(0, 3) : groupedEntries.slice(0, 4);
visibleEntries.forEach((item, index) => {
const tileClasses = ["chronoboard-month-entry"];
if (!item.task.color) {
tileClasses.push("is-default-color");
}
tileClasses.push(`slot-${index + 1}`);
const pill = list.createDiv({ cls: tileClasses.join(" ") });
this.applyTicketSurfaceStyle(pill, item.task, true);
pill.createDiv({
cls: "chronoboard-month-entry-key",
text: item.task.jiraKey || item.task.file.basename
});
pill.createDiv({
cls: "chronoboard-month-entry-hours",
text: formatHours(item.minutes)
});
});
if (groupedEntries.length >= 5) {
list.createDiv({
cls: "chronoboard-month-more slot-4",
text: `+${groupedEntries.length - 3} more`
});
}
}
}
groupMonthEntries(dayEntries) {
const grouped = new Map();
dayEntries.forEach(({ task, entry }) => {
const existing = grouped.get(task.path) || {
task,
minutes: 0,
earliestStart: entry.startTime,
count: 0
};
existing.minutes += this.entryMinutes(entry);
if (String(entry.startTime).localeCompare(String(existing.earliestStart)) < 0) {
existing.earliestStart = entry.startTime;
}
existing.count += 1;
grouped.set(task.path, existing);
});
return [...grouped.values()].sort((a, b) => {
if (b.minutes !== a.minutes) {
return b.minutes - a.minutes;
}
return String(a.earliestStart).localeCompare(String(b.earliestStart));
});
}
renderStatistics(panel, selectedTasks) {
const wrap = panel.createDiv({ cls: "chronoboard-stats-layout" });
const todayKey = moment().format("YYYY-MM-DD");
const monthStart = this.focusDate.clone().startOf("month");
const monthEnd = monthStart.clone().endOf("month");
const totalWeek = selectedTasks.reduce((sum, task) => sum + this.getMinutesForWeek(task.timeEntries || []), 0);
const totalMonth = selectedTasks.reduce((sum, task) => sum + this.getMinutesForMonth(task.timeEntries || [], monthStart, monthEnd), 0);
const totalToday = selectedTasks.reduce((sum, task) => sum + this.getMinutesForDay(task.timeEntries || [], todayKey), 0);
const cards = wrap.createDiv({ cls: "chronoboard-stats-cards" });
[
{ label: "Today", value: formatHours(totalToday) },
{ label: "This Week", value: formatHours(totalWeek) },
{ label: "This Month", value: formatHours(totalMonth) },
{ label: "Selected Tasks", value: String(selectedTasks.length) }
].forEach((stat) => {
const card = cards.createDiv({ cls: "chronoboard-stats-card" });
card.createDiv({ cls: "chronoboard-stats-value", text: stat.value });
card.createDiv({ cls: "chronoboard-stats-label", text: stat.label });
});
const weekSection = wrap.createDiv({ cls: "chronoboard-stats-section" });
weekSection.createDiv({ cls: "chronoboard-stats-section-title", text: "This Week by Day" });
const weekBars = weekSection.createDiv({ cls: "chronoboard-stats-bars" });
this.getWeekDays().forEach((dayMoment) => {
const dayKey = dayMoment.format("YYYY-MM-DD");
const minutes = selectedTasks.reduce((sum, task) => sum + this.getMinutesForDay(task.timeEntries || [], dayKey), 0);
const bar = weekBars.createDiv({ cls: "chronoboard-stats-bar-wrap" });
bar.createDiv({
cls: `chronoboard-stats-bar ${getHoursColor(minutes)}`,
attr: { style: `height:${Math.max((minutes / 480) * 160, 10)}px` }
});
bar.createDiv({ cls: "chronoboard-stats-bar-label", text: dayMoment.format("ddd") });
bar.createDiv({ cls: "chronoboard-stats-bar-value", text: formatHours(minutes) });
});
const taskSection = wrap.createDiv({ cls: "chronoboard-stats-section" });
taskSection.createDiv({ cls: "chronoboard-stats-section-title", text: "Top Weekly Tasks" });
const taskList = taskSection.createDiv({ cls: "chronoboard-stats-task-list" });
this.sortTasks(selectedTasks)
.sort((a, b) => this.getMinutesForWeek(b.timeEntries || []) - this.getMinutesForWeek(a.timeEntries || []))
.slice(0, 8)
.forEach((task) => {
const minutes = this.getMinutesForWeek(task.timeEntries || []);
const row = taskList.createDiv({ cls: "chronoboard-stats-task-row" });
const name = row.createDiv({ cls: "chronoboard-stats-task-name" });
name.createDiv({ cls: "chronoboard-task-key", text: task.jiraKey || task.file.basename });
const secondaryText = this.plugin.getTaskSecondaryText(task);
if (secondaryText) {
name.createDiv({ cls: "chronoboard-task-name", text: secondaryText });
}
row.createDiv({ cls: "chronoboard-stats-task-hours", text: formatHours(minutes) });
});
}
renderHourMarkers(container, horizontal, verticalHeight = null, horizontalWidth = null) {
const start = this.plugin.settings.visibleStartHour;
const end = this.plugin.settings.visibleEndHour;
for (let hour = start; hour < end; hour += 1) {
const marker = container.createDiv({
cls: horizontal ? "chronoboard-hour-marker-horizontal" : "chronoboard-hour-marker-vertical"
});
if (!horizontal && verticalHeight) {
marker.style.height = `${verticalHeight}px`;
}
if (horizontal && horizontalWidth) {
marker.style.width = `${horizontalWidth}px`;
}
marker.setText(moment({ hour }).format("h A").toLowerCase());
}
}
renderEntryBlock(container, entry, task, index, horizontal) {
const block = container.createDiv({
cls: `chronoboard-block${task.path === this.selectedTaskPath ? " is-selected-task" : ""}`
});
const renderedEntry = this.getRenderedEntryForBlock(task, index, entry);
const position = horizontal
? this.calculateHorizontalBlock(renderedEntry, container.clientWidth || container.getBoundingClientRect().width || this.getVisibleHourCount() * 84)
: this.calculateVerticalBlock(renderedEntry);
if (horizontal) {
block.style.left = `${position.startPx}px`;
block.style.width = `${position.sizePx}px`;
block.style.top = "2px";
block.style.bottom = "2px";
} else {
block.style.top = `${position.startPx}px`;
block.style.height = `${position.sizePx}px`;
}
const hideTimeLine = horizontal ? position.sizePx < 92 : false;
const showStatus = this.shouldDisplayTaskStatus(task) && (horizontal ? position.sizePx >= 150 : true);
this.applyTicketSurfaceStyle(block, task, true);
const head = block.createDiv({ cls: "chronoboard-block-head" });
head.createDiv({
cls: "chronoboard-task-name",
text: task.jiraKey || task.file.basename
});
if (showStatus) {
head.createDiv({
cls: `chronoboard-block-status ${getStatusToneClass(task.status)}`,
text: task.status
});
}
const secondaryText = this.plugin.getTaskSecondaryText(task);
if (secondaryText) {
block.createDiv({
cls: "chronoboard-block-subtitle",
text: secondaryText
});
}
if (!hideTimeLine) {
block.createDiv({
cls: "chronoboard-block-time",
text: `${moment(renderedEntry.startTime).format("HH:mm")} to ${moment(renderedEntry.endTime).format("HH:mm")}`
});
}
if (entry.label) {
block.createDiv({
cls: "chronoboard-block-notes",
text: entry.label
});
}
const startHandle = block.createDiv({
cls: `chronoboard-resize-handle ${horizontal ? "is-horizontal-start" : "is-vertical-start"}`
});
const endHandle = block.createDiv({
cls: `chronoboard-resize-handle ${horizontal ? "is-horizontal-end" : "is-vertical-end"}`
});
block.addEventListener("mousedown", (event) => {
event.stopPropagation();
if (event.button !== 0) {
return;
}
this.startPendingMoveHold(event, task, entry, index, horizontal, container);
});
block.addEventListener("mouseup", () => this.cancelPendingMoveHold());
block.addEventListener("mouseleave", () => this.cancelPendingMoveHold());
startHandle.addEventListener("mousedown", (event) => {
this.cancelPendingMoveHold();
event.preventDefault();
event.stopPropagation();
this.startResizeDrag(event, task, entry, index, horizontal, "start", container);
});
endHandle.addEventListener("mousedown", (event) => {
this.cancelPendingMoveHold();
event.preventDefault();
event.stopPropagation();
this.startResizeDrag(event, task, entry, index, horizontal, "end", container);
});
block.addEventListener("click", async (event) => {
if (event.button !== 0 || event.detail !== 4) {
return;
}
event.preventDefault();
event.stopPropagation();
await this.plugin.removeTimeEntry(task.file, entry, index, { pushUndo: true });
await this.render();
});
block.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
this.closeActiveContextMenu();
const menu = new Menu();
this.activeContextMenu = menu;
menu.addItem((item) =>
item
.setTitle("Open ticket note")
.setIcon("file-text")
.onClick(async () => {
await this.app.workspace.getLeaf("tab").openFile(task.file);
})
);
menu.addItem((item) =>
item
.setTitle("Change color")
.setIcon("palette")
.onClick(() => this.openColorModal(task))
);
menu.addItem((item) =>
item
.setTitle("Edit ticket subtitle")
.setIcon("text")
.onClick(() => this.openSubtitleModal(task))
);
menu.addItem((item) =>
item
.setTitle("Edit box notes")
.setIcon("text-cursor-input")
.onClick(() => {
new TimeBoxTextModal(this.app, {
initialText: entry.label || "",
onSave: async (label) => {
await this.plugin.updateTimeEntry(task.file, index, {
...entry,
label
});
await this.render();
}
}).open();
})
);
menu.addItem((item) =>
item
.setTitle(entry.notePath ? "Open time subnote" : "Create time subnote")
.setIcon("sticky-note")
.onClick(async () => {
await this.openOrCreateTimeEntryNote(task, index, entry);
})
);
menu.addItem((item) =>
item
.setTitle("Precise Edit Time")
.setIcon("pencil")
.onClick(() => {
new PreciseTimeEditModal(this.app, {
entry,
onSave: async (updatedEntry) => {
await this.plugin.updateTimeEntry(task.file, index, { ...entry, ...updatedEntry });
await this.render();
}
}).open();
})
);
menu.addItem((item) =>
item
.setTitle("Remove time block")
.setIcon("trash")
.onClick(async () => {
await this.plugin.removeTimeEntry(task.file, entry, index, { pushUndo: true });
await this.render();
})
);
menu.showAtMouseEvent(event);
});
}
renderDraftBlock(container, dragState, horizontal) {
const block = container.createDiv({ cls: "chronoboard-draft-block" });
const position = this.calculateDraftPosition(dragState, horizontal);
if (horizontal) {
block.style.left = `${position.startPx}px`;
block.style.width = `${position.sizePx}px`;
block.style.top = "8px";
block.style.bottom = "8px";
} else {
block.style.top = `${position.startPx}px`;
block.style.height = `${position.sizePx}px`;
}
}
getRenderedEntryForBlock(task, index, entry) {
if (
this.dragState &&
(this.dragState.action === "resize" || this.dragState.action === "move") &&
this.dragState.taskPath === task.path &&
this.dragState.entryIndex === index
) {
return (
this.dragState.action === "resize"
? this.entryFromResizeState(this.dragState)
: this.entryFromMoveState(this.dragState)
) || entry;
}
return entry;
}
startResizeDrag(event, task, entry, entryIndex, horizontal, containerEdge, container) {
if (event.button !== 0) {
return;
}
const rect = container.getBoundingClientRect();
this.dragState = {
action: "resize",
mode: horizontal ? "day" : "week",
taskPath: task.path,
file: task.file,
dayKey: moment(entry.startTime).format("YYYY-MM-DD"),
rect,
entryIndex,
edge: containerEdge,
originalEntry: { ...entry },
currentCoord: clampNumber(horizontal ? event.clientX - rect.left : event.clientY - rect.top, 0, horizontal ? rect.width : rect.height)
};
this.selectedTaskPath = task.path;
this.addDragListeners();
this.render();
}
startPendingMoveHold(event, task, entry, entryIndex, horizontal, container) {
this.cancelPendingMoveHold();
this.pendingMoveHold = window.setTimeout(() => {
const rect = container.getBoundingClientRect();
this.dragState = {
action: "move",
mode: horizontal ? "day" : "week",
taskPath: task.path,
file: task.file,
dayKey: moment(entry.startTime).format("YYYY-MM-DD"),
rect,
entryIndex,
originalEntry: { ...entry },
startCoord: clampNumber(horizontal ? event.clientX - rect.left : event.clientY - rect.top, 0, horizontal ? rect.width : rect.height),
currentCoord: clampNumber(horizontal ? event.clientX - rect.left : event.clientY - rect.top, 0, horizontal ? rect.width : rect.height)
};
this.selectedTaskPath = task.path;
this.addDragListeners();
this.render();
this.pendingMoveHold = null;
}, 500);
}
cancelPendingMoveHold() {
if (this.pendingMoveHold) {
window.clearTimeout(this.pendingMoveHold);
this.pendingMoveHold = null;
}
}
renderSummary(layout, selectedTasks) {
const scopedTasks = this.getScopedTasks(selectedTasks);
const panel = layout.createDiv({ cls: "chronoboard-panel" });
const panelHead = panel.createDiv({ cls: "chronoboard-panel-head" });
panelHead.createDiv({
text: this.currentMode === "day" ? "Daily Total" : this.currentMode === "month" ? "Monthly Total" : "Weekly Total"
});
panelHead.createDiv({
cls: "chronoboard-summary-head-total",
text: formatHours(scopedTasks.reduce((sum, task) => sum + this.getSummaryMinutesForTask(task), 0))
});
if (this.rightPanelCollapsed) {
return;
}
const list = panel.createDiv({ cls: "chronoboard-summary-list" });
if (!scopedTasks.length) {
list.createDiv({
cls: "chronoboard-empty",
text: "Weekly totals appear here once tasks are selected."
});
return;
}
scopedTasks.forEach((task) => {
const card = list.createDiv({
cls: `chronoboard-summary-card${task.path === this.selectedTaskPath ? " is-selected" : ""}${this.taskHasVisibleTime(task) ? " has-time" : ""}`
});
this.applyTicketSurfaceStyle(card, task, this.taskHasVisibleTime(task));
card.createDiv({ cls: "chronoboard-task-key", text: task.jiraKey || task.file.basename });
const secondaryText = this.plugin.getTaskSecondaryText(task);
if (secondaryText) {
card.createDiv({ cls: "chronoboard-task-name", text: secondaryText });
}
card.createDiv({ cls: "chronoboard-summary-hours", text: formatHours(this.getSummaryMinutesForTask(task)) });
card.createDiv({
cls: "chronoboard-summary-note",
text: this.currentMode === "day" ? "Tracked this day" : this.currentMode === "month" ? "Tracked this month" : "Tracked this week"
});
card.addEventListener("click", async () => {
this.selectedTaskPath = task.path;
await this.render();
});
card.addEventListener("contextmenu", (event) => this.openTicketContextMenu(event, task));
});
}
openTicketContextMenu(event, task) {
event.preventDefault();
event.stopPropagation();
this.closeActiveContextMenu();
const menu = new Menu();
this.activeContextMenu = menu;
menu.addItem((item) =>
item
.setTitle("Open ticket note")
.setIcon("file-text")
.onClick(async () => {
await this.app.workspace.getLeaf("tab").openFile(task.file);
})
);
menu.addItem((item) =>
item
.setTitle("Change color")
.setIcon("palette")
.onClick(() => this.openColorModal(task))
);
menu.addItem((item) =>
item
.setTitle("Edit ticket subtitle")
.setIcon("text")
.onClick(() => this.openSubtitleModal(task))
);
if (!this.isStaticTask(task.path)) {
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle("Remove Task")
.setIcon("trash")
.onClick(async () => {
this.plugin.settings.selectedTaskPaths = this.plugin.settings.selectedTaskPaths.filter((path) => path !== task.path);
const hasHistory = Array.isArray(task.timeEntries) && task.timeEntries.length > 0;
this.plugin.settings.boardOnlyTaskPaths = hasHistory
? [...new Set([...(this.plugin.settings.boardOnlyTaskPaths || []), task.path])]
: (this.plugin.settings.boardOnlyTaskPaths || []).filter((path) => path !== task.path);
await this.plugin.saveSettings();
if (this.selectedTaskPath === task.path) {
this.selectedTaskPath = this.getSelectedPaths()[0] || this.getBoardPaths()[0] || null;
}
await this.render();
})
);
}
menu.showAtMouseEvent(event);
this.decorateActiveMenu();
}
closeActiveContextMenu() {
if (this.activeContextMenu && typeof this.activeContextMenu.hide === "function") {
this.activeContextMenu.hide();
}
this.activeContextMenu = null;
document.querySelectorAll(".menu").forEach((element) => element.remove());
}
decorateActiveMenu() {
window.setTimeout(() => {
const menus = [...document.querySelectorAll(".menu")];
const menu = menus[menus.length - 1];
if (!menu) {
return;
}
const items = [...menu.querySelectorAll(".menu-item")];
const removeItem = items.find((item) => item.textContent && item.textContent.includes("Remove Task"));
if (removeItem) {
removeItem.classList.add("chronoboard-menu-remove-item");
}
const separators = [...menu.querySelectorAll(".menu-separator")];
const lastSeparator = separators[separators.length - 1];
if (lastSeparator && removeItem) {
lastSeparator.classList.add("chronoboard-menu-remove-separator");
}
}, 0);
}
openColorModal(task) {
new TicketColorModal(this.app, {
initialColor: task.color,
onSave: async (color) => {
await this.plugin.updateTicketColor(task.file, color);
await this.render();
},
onClear: async () => {
await this.plugin.updateTicketColor(task.file, "");
await this.render();
}
}).open();
}
openSubtitleModal(task) {
new TicketSubtitleModal(this.app, {
initialText: task.subtitle,
onSave: async (subtitle) => {
await this.plugin.updateTicketSubtitle(task.file, subtitle);
await this.render();
}
}).open();
}
applyTicketSurfaceStyle(element, task, isColoredState) {
const color = normalizeHexColor(task.color);
if (!color || !isColoredState) {
element.style.removeProperty("background");
element.style.removeProperty("border-color");
element.style.removeProperty("color");
return;
}
const textColor = this.plugin.settings.forceDarkTextOnColored ? "#111111" : getContrastTextColor(color);
element.style.background = color;
element.style.borderColor = color;
element.style.color = textColor;
element.style.setProperty("--chronoboard-custom-text", textColor);
}
async openOrCreateTimeEntryNote(task, entryIndex, entry) {
const entryId = entry.id || createEntryId();
if (entry.notePath) {
const existing = this.app.vault.getAbstractFileByPath(entry.notePath);
if (existing instanceof TFile) {
if (!entry.id) {
await this.plugin.updateTimeEntry(task.file, entryIndex, {
...entry,
id: entryId
});
}
await this.app.workspace.getLeaf("tab").openFile(existing);
return;
}
}
const folder = this.plugin.settings.timeEntryNotesFolder;
if (!this.app.vault.getAbstractFileByPath(folder)) {
await this.app.vault.createFolder(folder);
}
const key = task.jiraKey || task.file.basename;
const start = moment(entry.startTime).format("YYYY-MM-DD HHmm");
const labelSegment = sanitizeFileSegment(entry.label || "");
const baseName = sanitizeFileSegment(
labelSegment ? `${key} ${labelSegment} ${start}` : `${key} ${start}`
);
let filePath = `${folder}/${baseName}.md`;
let counter = 2;
while (this.app.vault.getAbstractFileByPath(filePath)) {
filePath = `${folder}/${baseName} ${counter}.md`;
counter += 1;
}
const body = [
"---",
`Date: ${moment().format("YYYY-MM-DD HH:mm")}`,
`Updated: ${moment().format("YYYY-MM-DD HH:mm")}`,
"aliases:",
` - ${entryId}`,
"tags:",
" - chronoboard",
`Links: "[[${task.file.basename}]]"`,
`ChronoboardEntryId: ${entryId}`,
`ChronoboardParent: "[[${task.file.basename}]]"`,
`ChronoboardStart: ${entry.startTime}`,
`ChronoboardEnd: ${entry.endTime}`,
"---",
""
].join("\n");
const noteFile = await this.app.vault.create(filePath, body);
await this.plugin.updateTimeEntry(task.file, entryIndex, {
...entry,
id: entryId,
notePath: noteFile.path
});
await this.app.workspace.getLeaf("tab").openFile(noteFile);
}
handleWeekMouseDown(event, selectedTask, dayMoment, column) {
if (event.button !== 0 || !selectedTask) {
return;
}
const rect = column.getBoundingClientRect();
this.dragState = {
action: "create",
mode: "week",
taskPath: selectedTask.path,
file: selectedTask.file,
dayKey: dayMoment.format("YYYY-MM-DD"),
rect,
startCoord: clampNumber(event.clientY - rect.top, 0, rect.height),
currentCoord: clampNumber(event.clientY - rect.top, 0, rect.height)
};
this.addDragListeners();
this.render();
}
handleDayMouseDown(event, task, dayMoment, row) {
if (event.button !== 0) {
return;
}
const rect = row.getBoundingClientRect();
this.dragState = {
action: "create",
mode: "day",
taskPath: task.path,
file: task.file,
dayKey: dayMoment.format("YYYY-MM-DD"),
rect,
startCoord: clampNumber(event.clientX - rect.left, 0, rect.width),
currentCoord: clampNumber(event.clientX - rect.left, 0, rect.width)
};
this.selectedTaskPath = task.path;
this.addDragListeners();
this.render();
}
addDragListeners() {
this.removeDragListeners();
window.addEventListener("mousemove", this.boundMouseMove);
window.addEventListener("mouseup", this.boundMouseUp);
}
removeDragListeners() {
window.removeEventListener("mousemove", this.boundMouseMove);
window.removeEventListener("mouseup", this.boundMouseUp);
}
handleMouseMove(event) {
if (!this.dragState) {
return;
}
if (this.dragState.mode === "week") {
this.dragState.currentCoord = clampNumber(event.clientY - this.dragState.rect.top, 0, this.dragState.rect.height);
} else {
this.dragState.currentCoord = clampNumber(event.clientX - this.dragState.rect.left, 0, this.dragState.rect.width);
}
this.render();
}
async handleMouseUp() {
this.cancelPendingMoveHold();
if (!this.dragState) {
return;
}
const dragState = { ...this.dragState };
this.dragState = null;
this.removeDragListeners();
if (dragState.action === "resize") {
const updatedEntry = this.entryFromResizeState(dragState);
if (
updatedEntry &&
(updatedEntry.startTime !== dragState.originalEntry.startTime || updatedEntry.endTime !== dragState.originalEntry.endTime)
) {
await this.plugin.updateTimeEntry(dragState.file, dragState.entryIndex, {
...dragState.originalEntry,
...updatedEntry
});
}
} else if (dragState.action === "move") {
const updatedEntry = this.entryFromMoveState(dragState);
if (
updatedEntry &&
(updatedEntry.startTime !== dragState.originalEntry.startTime || updatedEntry.endTime !== dragState.originalEntry.endTime)
) {
await this.plugin.updateTimeEntry(dragState.file, dragState.entryIndex, {
...dragState.originalEntry,
...updatedEntry
});
}
} else {
const entry = this.entryFromDragState(dragState);
if (entry) {
await this.plugin.addTimeEntry(dragState.file, entry, { pushUndo: true });
}
}
await this.render();
}
calculateDraftPosition(dragState, horizontal) {
const start = Math.min(dragState.startCoord, dragState.currentCoord);
const end = Math.max(dragState.startCoord, dragState.currentCoord);
return {
startPx: start,
sizePx: Math.max(end - start, horizontal ? 8 : 8)
};
}
entryFromDragState(dragState) {
if (Math.abs(dragState.currentCoord - dragState.startCoord) < 6) {
return null;
}
const dayMoment = moment(dragState.dayKey, "YYYY-MM-DD");
const startCoord = Math.min(dragState.startCoord, dragState.currentCoord);
const endCoord = Math.max(dragState.startCoord, dragState.currentCoord);
const totalMinutes = this.getVisibleHourCount() * 60;
let startMinutesIntoWindow;
let endMinutesIntoWindow;
if (dragState.mode === "week") {
startMinutesIntoWindow = (startCoord / dragState.rect.height) * totalMinutes;
endMinutesIntoWindow = (endCoord / dragState.rect.height) * totalMinutes;
} else {
startMinutesIntoWindow = (startCoord / dragState.rect.width) * totalMinutes;
endMinutesIntoWindow = (endCoord / dragState.rect.width) * totalMinutes;
}
const roundedStart = Math.floor(startMinutesIntoWindow / 15) * 15;
const roundedEnd = Math.ceil(endMinutesIntoWindow / 15) * 15;
if (roundedEnd <= roundedStart) {
return null;
}
const start = dayMoment.clone().startOf("day").add(this.plugin.settings.visibleStartHour * 60 + roundedStart, "minutes");
const end = dayMoment.clone().startOf("day").add(this.plugin.settings.visibleStartHour * 60 + roundedEnd, "minutes");
return {
startTime: start.format("YYYY-MM-DDTHH:mm"),
endTime: end.format("YYYY-MM-DDTHH:mm")
};
}
entryFromResizeState(dragState) {
if (!dragState || dragState.action !== "resize") {
return null;
}
const totalMinutes = this.getVisibleHourCount() * 60;
const totalPixels = dragState.mode === "week" ? dragState.rect.height : dragState.rect.width;
const originalEntry = dragState.originalEntry;
const originalStart = parseFrontmatterDate(originalEntry.startTime);
const originalEnd = parseFrontmatterDate(originalEntry.endTime);
if (!originalStart || !originalEnd || totalPixels <= 0) {
return null;
}
const visibleStartMinutes = this.plugin.settings.visibleStartHour * 60;
const startWithinWindow = clampNumber(originalStart.hours() * 60 + originalStart.minutes(), visibleStartMinutes, this.plugin.settings.visibleEndHour * 60);
const endWithinWindow = clampNumber(originalEnd.hours() * 60 + originalEnd.minutes(), visibleStartMinutes, this.plugin.settings.visibleEndHour * 60);
let startMinutesIntoWindow = startWithinWindow - visibleStartMinutes;
let endMinutesIntoWindow = endWithinWindow - visibleStartMinutes;
const currentMinutes = (dragState.currentCoord / totalPixels) * totalMinutes;
if (dragState.edge === "start") {
startMinutesIntoWindow = Math.floor(currentMinutes / 15) * 15;
startMinutesIntoWindow = clampNumber(startMinutesIntoWindow, 0, Math.max(endMinutesIntoWindow - 15, 0));
} else {
endMinutesIntoWindow = Math.ceil(currentMinutes / 15) * 15;
endMinutesIntoWindow = clampNumber(endMinutesIntoWindow, Math.min(startMinutesIntoWindow + 15, totalMinutes), totalMinutes);
}
if (endMinutesIntoWindow <= startMinutesIntoWindow) {
return null;
}
const dayMoment = moment(dragState.dayKey, "YYYY-MM-DD");
const start = dayMoment.clone().startOf("day").add(visibleStartMinutes + startMinutesIntoWindow, "minutes");
const end = dayMoment.clone().startOf("day").add(visibleStartMinutes + endMinutesIntoWindow, "minutes");
return {
startTime: start.format("YYYY-MM-DDTHH:mm"),
endTime: end.format("YYYY-MM-DDTHH:mm")
};
}
entryFromMoveState(dragState) {
if (!dragState || dragState.action !== "move") {
return null;
}
const totalMinutes = this.getVisibleHourCount() * 60;
const totalPixels = dragState.mode === "week" ? dragState.rect.height : dragState.rect.width;
const originalEntry = dragState.originalEntry;
const originalStart = parseFrontmatterDate(originalEntry.startTime);
const originalEnd = parseFrontmatterDate(originalEntry.endTime);
if (!originalStart || !originalEnd || totalPixels <= 0) {
return null;
}
const deltaPixels = dragState.currentCoord - dragState.startCoord;
const deltaMinutes = Math.round(((deltaPixels / totalPixels) * totalMinutes) / 15) * 15;
const visibleStartMinutes = this.plugin.settings.visibleStartHour * 60;
const visibleEndMinutes = this.plugin.settings.visibleEndHour * 60;
const durationMinutes = Math.max(originalEnd.diff(originalStart, "minutes"), 15);
let movedStartMinutes = originalStart.hours() * 60 + originalStart.minutes() + deltaMinutes;
movedStartMinutes = clampNumber(movedStartMinutes, visibleStartMinutes, visibleEndMinutes - durationMinutes);
const dayMoment = moment(dragState.dayKey, "YYYY-MM-DD");
const start = dayMoment.clone().startOf("day").add(movedStartMinutes, "minutes");
const end = start.clone().add(durationMinutes, "minutes");
return {
startTime: start.format("YYYY-MM-DDTHH:mm"),
endTime: end.format("YYYY-MM-DDTHH:mm")
};
}
calculateVerticalBlock(entry) {
const start = parseFrontmatterDate(entry.startTime);
const end = parseFrontmatterDate(entry.endTime);
const visibleStartMinutes = this.plugin.settings.visibleStartHour * 60;
const visibleEndMinutes = this.plugin.settings.visibleEndHour * 60;
const entryStart = clampNumber(start.hours() * 60 + start.minutes(), visibleStartMinutes, visibleEndMinutes);
const entryEnd = clampNumber(end.hours() * 60 + end.minutes(), visibleStartMinutes, visibleEndMinutes);
const hourHeight = this.currentHourHeight || PX_PER_HOUR;
const startPx = ((entryStart - visibleStartMinutes) / 60) * hourHeight;
const sizePx = Math.max(((entryEnd - entryStart) / 60) * hourHeight, 12);
return { startPx, sizePx };
}
calculateHorizontalBlock(entry, totalWidth) {
const start = parseFrontmatterDate(entry.startTime);
const end = parseFrontmatterDate(entry.endTime);
const visibleStartMinutes = this.plugin.settings.visibleStartHour * 60;
const visibleEndMinutes = this.plugin.settings.visibleEndHour * 60;
const pxPerMinute = (totalWidth / this.getVisibleHourCount()) / 60;
const entryStart = clampNumber(start.hours() * 60 + start.minutes(), visibleStartMinutes, visibleEndMinutes);
const entryEnd = clampNumber(end.hours() * 60 + end.minutes(), visibleStartMinutes, visibleEndMinutes);
const startPx = (entryStart - visibleStartMinutes) * pxPerMinute;
const sizePx = Math.max((entryEnd - entryStart) * pxPerMinute, 12);
return { startPx, sizePx };
}
entryMinutes(entry) {
const start = parseFrontmatterDate(entry.startTime);
const end = parseFrontmatterDate(entry.endTime);
if (!start || !end) {
return 0;
}
return Math.max(end.diff(start, "minutes"), 0);
}
getMinutesForWeek(entries) {
const weekStart = this.focusDate.clone().startOf("isoWeek").startOf("day");
const weekEnd = weekStart.clone().add(7, "days");
return entries.reduce((sum, entry) => {
const start = parseFrontmatterDate(entry.startTime);
if (!start || start.isBefore(weekStart) || !start.isBefore(weekEnd)) {
return sum;
}
return sum + this.entryMinutes(entry);
}, 0);
}
getMinutesForDay(entries, dayKey) {
return entries.reduce((sum, entry) => {
const start = parseFrontmatterDate(entry.startTime);
if (!start || start.format("YYYY-MM-DD") !== dayKey) {
return sum;
}
return sum + this.entryMinutes(entry);
}, 0);
}
getMinutesForMonth(entries, monthStart, monthEnd) {
return entries.reduce((sum, entry) => {
const start = parseFrontmatterDate(entry.startTime);
if (!start || start.isBefore(monthStart) || start.isAfter(monthEnd)) {
return sum;
}
return sum + this.entryMinutes(entry);
}, 0);
}
getSummaryMinutesForTask(task) {
if (this.currentMode === "day") {
return this.getMinutesForDay(task.timeEntries || [], this.focusDate.clone().startOf("day").format("YYYY-MM-DD"));
}
if (this.currentMode === "month") {
const monthStart = this.focusDate.clone().startOf("month");
const monthEnd = monthStart.clone().endOf("month");
return this.getMinutesForMonth(task.timeEntries || [], monthStart, monthEnd);
}
return this.getMinutesForWeek(task.timeEntries || []);
}
taskHasVisibleTime(task) {
if (!task || !Array.isArray(task.timeEntries)) {
return false;
}
if (this.currentMode === "week") {
return this.getMinutesForWeek(task.timeEntries) > 0;
}
if (this.currentMode === "day") {
const dayKey = this.focusDate.clone().startOf("day").format("YYYY-MM-DD");
return task.timeEntries.some((entry) => {
const start = parseFrontmatterDate(entry.startTime);
return start && start.format("YYYY-MM-DD") === dayKey;
});
}
return task.timeEntries.length > 0;
}
}
module.exports = class ChronoboardPlugin extends Plugin {
async onload() {
await this.loadSettings();
this.undoStack = [];
this.registerView(VIEW_TYPE, (leaf) => new ChronoboardView(leaf, this));
this.addRibbonIcon("blocks", "Open Chronoboard", async () => {
await this.activateView();
});
this.addCommand({
id: "open-chronoboard",
name: "Open Chronoboard",
callback: async () => this.activateView()
});
this.addCommand({
id: "make-current-note-tasknotes-compatible",
name: "Add TaskNotes fields to current note",
checkCallback: (checking) => {
const file = this.app.workspace.getActiveFile();
if (!(file instanceof TFile) || file.extension !== "md") {
return false;
}
if (!checking) {
this.ensureTaskNoteShape(file);
}
return true;
}
});
this.addSettingTab(new ChronoboardSettingTab(this.app, this));
this.registerEvent(this.app.metadataCache.on("changed", () => this.refreshAllViews()));
this.registerEvent(this.app.vault.on("rename", () => this.refreshAllViews()));
this.registerEvent(this.app.vault.on("delete", () => this.refreshAllViews()));
this.registerDomEvent(document, "keydown", (event) => this.handleUndoKeydown(event));
}
async onunload() {
await this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings.highlightColor = normalizeHexColor(this.settings.highlightColor);
}
async saveSettings() {
await this.saveData(this.settings);
}
pushUndoAction(action) {
this.undoStack.push(action);
if (this.undoStack.length > 100) {
this.undoStack.shift();
}
}
async handleUndoKeydown(event) {
const isUndo = (event.ctrlKey || event.metaKey) && !event.shiftKey && String(event.key).toLowerCase() === "z";
if (!isUndo || !this.undoStack.length) {
return;
}
event.preventDefault();
const action = this.undoStack.pop();
if (!action) {
return;
}
if (action.type === "remove-entry") {
await this.addTimeEntry(action.file, action.entry, { pushUndo: false });
return;
}
if (action.type === "add-entry") {
await this.removeTimeEntry(action.file, action.entry, action.entryIndex, { pushUndo: false });
}
}
async activateView() {
let leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE)[0];
if (!leaf) {
leaf = this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE, active: true });
}
this.app.workspace.revealLeaf(leaf);
if (leaf.view && typeof leaf.view.refresh === "function") {
await leaf.view.refresh();
}
}
async refreshAllViews() {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE)) {
if (leaf.view && typeof leaf.view.refresh === "function") {
await leaf.view.refresh();
}
}
}
async getAvailableTasks() {
const files = this.app.vault.getMarkdownFiles().filter((file) => file.path.startsWith(this.settings.folder));
const tasks = [];
for (const file of files) {
const task = this.getTaskFromFile(file);
if (!task) {
continue;
}
const rawContent = await this.app.vault.cachedRead(file);
if (rawFrontmatterContainsExcludedValue(rawContent, this.settings.filterProperty, this.settings.excludedValues || [])) {
continue;
}
const rawValues = extractStatusValuesFromText(rawContent, this.settings.filterProperty);
const allValues = [...new Set([...(task.filterValues || []), ...rawValues])];
if (allValues.some((value) => (this.settings.excludedValues || []).includes(value))) {
continue;
}
tasks.push(task);
}
tasks.sort((a, b) => {
const rankDiff = getStatusSortRank(a.status) - getStatusSortRank(b.status);
if (rankDiff !== 0) {
return rankDiff;
}
return a.displayTitle.localeCompare(b.displayTitle);
});
return tasks;
}
async getTaskMap(paths) {
const map = new Map();
for (const path of paths) {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
continue;
}
const task = this.getTaskFromFile(file);
if (task) {
map.set(path, task);
}
}
return map;
}
getTaskFromFile(file) {
const cache = this.app.metadataCache.getFileCache(file) || {};
const frontmatter = cache.frontmatter || {};
const jiraKey = this.extractJiraKey(file, frontmatter);
const title = String(frontmatter.title || "").trim();
const rawFilter = frontmatter[this.settings.filterProperty] ?? frontmatter.status ?? "Ongoing";
const filterValues = normalizeStatusList(rawFilter);
const status = filterValues[0] || "ongoing";
const timeEntries = Array.isArray(frontmatter.timeEntries) ? frontmatter.timeEntries.filter((entry) => entry && entry.startTime && entry.endTime) : [];
const activeValue = frontmatter[this.settings.activeProperty];
return {
file,
path: file.path,
jiraKey,
displayTitle: title || file.basename,
status,
rawStatus: rawFilter,
filterValues,
timeEntries,
active: activeValue === true || activeValue === "true",
color: normalizeHexColor(frontmatter[this.settings.colorProperty] || ""),
subtitle: String(frontmatter[this.settings.subtitleProperty] || "").trim()
};
}
getTaskSecondaryText(task) {
const subtitle = String(task?.subtitle || "").trim();
if (subtitle) {
return subtitle;
}
const key = String(task?.jiraKey || task?.file?.basename || "").trim();
const title = String(task?.displayTitle || "").trim();
if (title && title !== key) {
return title;
}
return "";
}
extractTags(frontmatter) {
const values = [];
const raw = frontmatter.tags;
if (Array.isArray(raw)) {
raw.forEach((value) => values.push(String(value).replace(/^#/, "").trim()));
} else if (typeof raw === "string") {
raw.split(/[\s,]+/).forEach((value) => {
if (value.trim()) {
values.push(value.replace(/^#/, "").trim());
}
});
}
return values.filter(Boolean);
}
extractJiraKey(file, frontmatter) {
const direct = String(frontmatter.jira || "").trim();
if (direct) {
return direct;
}
const match = file.basename.match(/[A-Z][A-Z0-9]+-\d+/);
return match ? match[0] : file.basename;
}
async ensureTaskNoteShape(file) {
try {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
const tags = new Set(this.extractTags(frontmatter));
tags.add(this.settings.taskTag);
tags.add(this.settings.jiraTag);
frontmatter.tags = [...tags];
if (!frontmatter.title) {
frontmatter.title = file.basename;
}
if (!frontmatter[this.settings.filterProperty]) {
frontmatter[this.settings.filterProperty] = "Ongoing";
}
if (!frontmatter.status) {
frontmatter.status = "in-progress";
}
if (frontmatter[this.settings.activeProperty] === undefined) {
frontmatter[this.settings.activeProperty] = true;
}
if (!Array.isArray(frontmatter.timeEntries)) {
frontmatter.timeEntries = [];
}
if (frontmatter.timeEstimate === undefined) {
frontmatter.timeEstimate = 0;
}
});
new Notice(`TaskNotes fields added to ${file.basename}`);
await this.refreshAllViews();
} catch (error) {
console.error(error);
new Notice("Failed to update note frontmatter");
}
}
async addTimeEntry(file, entry, options = {}) {
let storedEntry = null;
let storedIndex = -1;
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
const existing = Array.isArray(frontmatter.timeEntries) ? [...frontmatter.timeEntries] : [];
storedEntry = {
...entry,
id: entry.id || createEntryId()
};
existing.push(storedEntry);
existing.sort((a, b) => String(a.startTime).localeCompare(String(b.startTime)));
storedIndex = existing.findIndex((candidate) => candidate.id === storedEntry.id);
frontmatter.timeEntries = existing;
if (frontmatter[this.settings.activeProperty] === undefined) {
frontmatter[this.settings.activeProperty] = true;
}
});
if (options.pushUndo && storedEntry) {
this.pushUndoAction({
type: "add-entry",
file,
entry: storedEntry,
entryIndex: storedIndex
});
}
await this.refreshAllViews();
return { entry: storedEntry, entryIndex: storedIndex };
}
async removeTimeEntry(file, entry, entryIndex, options = {}) {
let removedEntry = null;
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
const existing = Array.isArray(frontmatter.timeEntries) ? [...frontmatter.timeEntries] : [];
frontmatter.timeEntries = existing.filter((candidate, index) => {
const matchesById = entry.id && candidate.id === entry.id;
const matchesByIndex = index === entryIndex && candidate.startTime === entry.startTime && candidate.endTime === entry.endTime;
if (!matchesById && !matchesByIndex) {
return true;
}
removedEntry = candidate;
return false;
});
});
if (options.pushUndo && removedEntry) {
this.pushUndoAction({
type: "remove-entry",
file,
entry: removedEntry
});
}
await this.refreshAllViews();
return removedEntry;
}
async updateTimeEntry(file, entryIndex, updatedEntry) {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
const existing = Array.isArray(frontmatter.timeEntries) ? [...frontmatter.timeEntries] : [];
if (entryIndex >= 0 && entryIndex < existing.length) {
existing[entryIndex] = updatedEntry;
}
existing.sort((a, b) => String(a.startTime).localeCompare(String(b.startTime)));
frontmatter.timeEntries = existing;
});
await this.refreshAllViews();
}
async updateTicketColor(file, color) {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (color) {
frontmatter[this.settings.colorProperty] = color;
} else {
delete frontmatter[this.settings.colorProperty];
}
});
await this.refreshAllViews();
}
async updateTicketSubtitle(file, subtitle) {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (subtitle) {
frontmatter[this.settings.subtitleProperty] = subtitle;
} else {
delete frontmatter[this.settings.subtitleProperty];
}
});
await this.refreshAllViews();
}
};