mirror of
https://github.com/emberleif/Chronoboard.git
synced 2026-07-22 07:44:56 +00:00
2440 lines
85 KiB
JavaScript
2440 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();
|
||
|
|
}
|
||
|
|
};
|