commit e8fd7a04e6c9b8e4d0036f5c970a5ae01b4faf45 Author: Emberleif Date: Fri Jun 12 17:06:56 2026 -0500 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a07445 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d353530 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Emberleif + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eac486e --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Chronoboard + +Chronoboard is an Obsidian community plugin for visual, note-native time tracking. + +It turns Markdown notes into draggable timeboard entries across day, week, and month views while writing all data back into frontmatter. It works well with TaskNotes-style task notes, but it is not limited to Jira workflows or any specific vault structure. + +## Screenshots + +### Week view + +![Chronoboard week view](assets/chronoboard-week.png) + +### Day view + +![Chronoboard day view](assets/chronoboard-day.png) + +### Month view + +![Chronoboard month view](assets/chronoboard-month.png) + +## Features + +- Day, week, month, and statistics views +- Click and drag time entry creation +- Resize and move time blocks directly on the board +- Right-click actions for editing, recoloring, opening notes, and removing blocks +- Scoped totals for day, week, and month +- Support for always-included static notes such as meetings +- Frontmatter-based data with no separate database + +## How it works + +Chronoboard reads notes from a configured folder, filters them using a metadata property, and lets you place time visually on a board. Time entries are written back into each note's YAML frontmatter, so your data stays portable and queryable. + +## Frontmatter + +Chronoboard reads and writes these properties when present: + +```yaml +title: "Example Task" +Status: "In Progress" +active: true +timeboardColor: "#7c5cff" +timeboardSubtitle: "Project subtitle" +timeEntries: + - startTime: "2026-06-12T09:00:00" + endTime: "2026-06-12T11:00:00" + label: "Worked on API review" +``` + +`timeEntries` is the only required structure for tracking time. Everything else is configurable or optional. + +## Settings + +- `Folder`: source folder for notes that appear in the add-task picker +- `Metadata property`: property used to filter notes out of the picker +- `Excluded values`: values that hide notes from the picker +- `Always include notes`: static notes that always appear in the task pool +- `Visible start hour` and `Visible end hour` +- `Hide weekends in week view` +- `Highlight color` +- `Force dark text on colored cards` + +## Release files + +- `manifest.json` +- `main.js` +- `styles.css` +- `versions.json` diff --git a/assets/chronoboard-day.png b/assets/chronoboard-day.png new file mode 100644 index 0000000..d37e7cb Binary files /dev/null and b/assets/chronoboard-day.png differ diff --git a/assets/chronoboard-month.png b/assets/chronoboard-month.png new file mode 100644 index 0000000..be163f6 Binary files /dev/null and b/assets/chronoboard-month.png differ diff --git a/assets/chronoboard-week.png b/assets/chronoboard-week.png new file mode 100644 index 0000000..84ea577 Binary files /dev/null and b/assets/chronoboard-week.png differ diff --git a/main.js b/main.js new file mode 100644 index 0000000..a4fbaef --- /dev/null +++ b/main.js @@ -0,0 +1,2439 @@ +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(); + } +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2430aa4 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "chronoboard", + "name": "Chronoboard", + "version": "0.6.0", + "minAppVersion": "1.5.0", + "description": "A Gantt-style timeboard for project-based time tracking across notes.", + "author": "Emberleif", + "isDesktopOnly": false +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..66abc8f --- /dev/null +++ b/styles.css @@ -0,0 +1,1151 @@ +.chronoboard-view { + height: 100%; + display: flex; + flex-direction: column; + color: var(--text-normal); + background: var(--background-primary); +} + +.chronoboard-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); +} + +.chronoboard-toolbar-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.chronoboard-title { + font-size: var(--font-ui-medium); + font-weight: 600; +} + +.chronoboard-subtle { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.chronoboard-pill { + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + border-radius: 6px; + padding: 5px 10px; + font-size: var(--font-ui-smaller); + user-select: none; +} + +.chronoboard-pill.is-active { + background: var(--chronoboard-highlight, var(--interactive-accent)); + color: var(--text-on-accent); + border-color: var(--chronoboard-highlight, var(--interactive-accent)); +} + +.chronoboard-layout { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: 220px minmax(0, 1fr) 210px; +} + +.chronoboard-layout.is-left-collapsed { + grid-template-columns: 0 minmax(0, 1fr) 210px; +} + +.chronoboard-layout.is-right-collapsed { + grid-template-columns: 220px minmax(0, 1fr) 0; +} + +.chronoboard-layout.is-left-collapsed.is-right-collapsed { + grid-template-columns: 0 minmax(0, 1fr) 0; +} + +.chronoboard-panel { + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chronoboard-panel + .chronoboard-panel { + border-left: 1px solid var(--background-modifier-border); +} + +.chronoboard-panel-head { + height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + font-size: var(--font-ui-small); + font-weight: 600; +} + +.chronoboard-task-list, +.chronoboard-summary-list { + padding: 8px; + display: grid; + gap: 8px; + overflow: auto; +} + +.chronoboard-task-card, +.chronoboard-summary-card, +.chronoboard-add-slot, +.chronoboard-day-task-card { + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-primary); +} + +.chronoboard-task-card { + display: flex; + align-items: stretch; + justify-content: flex-start; + gap: 8px; + padding: 10px 12px; + cursor: pointer; +} + +.chronoboard-task-card.is-selected, +.chronoboard-summary-card.is-selected, +.chronoboard-day-task-card.is-selected { + background: var(--background-primary-alt); + border-color: var(--chronoboard-highlight, var(--interactive-accent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chronoboard-highlight, var(--interactive-accent)) 65%, white); +} + +.chronoboard-task-card.has-time, +.chronoboard-summary-card.has-time, +.chronoboard-day-task-card.has-time { + background: var(--interactive-accent); + border-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.chronoboard-task-card.has-time.is-selected, +.chronoboard-summary-card.has-time.is-selected, +.chronoboard-day-task-card.has-time.is-selected { + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.62); +} + +.chronoboard-task-card.has-time .chronoboard-task-key, +.chronoboard-summary-card.has-time .chronoboard-task-key, +.chronoboard-day-task-card.has-time .chronoboard-task-key { + color: rgba(255, 255, 255, 0.96); +} + +.chronoboard-task-card.has-time .chronoboard-task-meta, +.chronoboard-summary-card.has-time .chronoboard-summary-note, +.chronoboard-summary-card.has-time .chronoboard-summary-meta, +.chronoboard-day-task-card.has-time .chronoboard-task-meta { + color: rgba(255, 255, 255, 0.86); +} + +.chronoboard-task-card.has-time .chronoboard-task-key, +.chronoboard-summary-card.has-time .chronoboard-task-key, +.chronoboard-day-task-card.has-time .chronoboard-task-key, +.chronoboard-task-card.has-time .chronoboard-task-name, +.chronoboard-summary-card.has-time .chronoboard-task-name, +.chronoboard-day-task-card.has-time .chronoboard-task-name, +.chronoboard-block .chronoboard-task-name, +.chronoboard-block .chronoboard-block-subtitle, +.chronoboard-block .chronoboard-block-time, +.chronoboard-block .chronoboard-block-notes { + color: var(--chronoboard-custom-text, inherit); +} + +.chronoboard-task-main { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; +} + +.chronoboard-task-info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.chronoboard-task-key { + color: var(--interactive-accent); + font-size: var(--font-ui-smaller); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.chronoboard-task-name { + font-size: 12px; + font-weight: 500; + white-space: normal; + overflow-wrap: anywhere; +} + +.chronoboard-task-meta, +.chronoboard-summary-meta, +.chronoboard-summary-note { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.chronoboard-status { + display: block; + margin-top: 2px; + white-space: nowrap; + text-transform: lowercase; + font-weight: 600; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.18); +} + +.chronoboard-status.is-in-progress, +.chronoboard-block-status.is-in-progress, +.chronoboard-day-total.is-in-progress { + color: #4ea0ff; +} + +.chronoboard-status.is-ongoing, +.chronoboard-block-status.is-ongoing, +.chronoboard-day-total.is-ongoing { + color: #2fb859; +} + +.chronoboard-status.is-on-hold, +.chronoboard-block-status.is-on-hold, +.chronoboard-day-total.is-on-hold { + color: #d29119; +} + +.chronoboard-status.is-upcoming, +.chronoboard-block-status.is-upcoming, +.chronoboard-day-total.is-upcoming { + color: #9f63f2; +} + +.chronoboard-task-card.has-time .chronoboard-status.is-in-progress, +.chronoboard-day-task-card.has-time .chronoboard-status.is-in-progress { + color: #4ea0ff; +} + +.chronoboard-task-card.has-time .chronoboard-status.is-ongoing, +.chronoboard-day-task-card.has-time .chronoboard-status.is-ongoing { + color: #2fb859; +} + +.chronoboard-task-card.has-time .chronoboard-status.is-on-hold, +.chronoboard-day-task-card.has-time .chronoboard-status.is-on-hold { + color: #d29119; +} + +.chronoboard-task-card.has-time .chronoboard-status.is-upcoming, +.chronoboard-day-task-card.has-time .chronoboard-status.is-upcoming { + color: #9f63f2; +} + +.chronoboard-icon-button, +.chronoboard-nav-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 0; + background: transparent; + color: var(--text-faint); + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + line-height: 1; +} + +.chronoboard-icon-button:hover, +.chronoboard-nav-button:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.chronoboard-collapse-button { + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + color: var(--text-muted); + border-radius: 6px; + padding: 4px 8px; + font-size: 11px; + cursor: pointer; +} + +.chronoboard-collapse-button:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.chronoboard-stats-button { + border: 1px solid color-mix(in srgb, var(--chronoboard-highlight, var(--interactive-accent)) 55%, black); + background: linear-gradient(180deg, color-mix(in srgb, var(--chronoboard-highlight, var(--interactive-accent)) 92%, white), var(--chronoboard-highlight, var(--interactive-accent))); + color: var(--text-on-accent); + border-radius: 6px; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.chronoboard-stats-button.is-active { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5); +} + +.chronoboard-add-slot { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 12px; + border-style: dashed; + color: var(--text-muted); + cursor: pointer; +} + +.chronoboard-add-slot:hover { + background: var(--background-modifier-hover); +} + +.chronoboard-plus { + width: 22px; + height: 22px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--background-modifier-border); + font-size: 16px; + color: var(--text-normal); +} + +.chronoboard-empty, +.chronoboard-month-placeholder { + padding: 16px 12px; + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.chronoboard-week-layout { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + grid-template-rows: 38px minmax(0, 1fr); + min-height: 0; + flex: 1; + overflow: hidden; +} + +.chronoboard-week-corner, +.chronoboard-day-left-head { + display: flex; + align-items: center; + padding: 0 8px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-muted); + font-size: 11px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.chronoboard-week-corner { + color: transparent; + position: sticky; + top: 0; + z-index: 3; +} + +.chronoboard-week-head { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + position: sticky; + top: 0; + z-index: 2; + border-left: 1px solid var(--background-modifier-border); +} + +.chronoboard-day { + padding: 6px 8px; + border-left: 1px solid var(--background-modifier-border); +} + +.chronoboard-day:first-child { + border-left: 0; +} + +.chronoboard-day-name { + display: block; + font-size: var(--font-ui-smaller); + font-weight: 600; +} + +.chronoboard-day-date { + display: block; + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.chronoboard-day-head-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.chronoboard-day-head-left { + min-width: 0; +} + +.chronoboard-day-total { + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.chronoboard-week-hours { + overflow: hidden; + border-right: 1px solid var(--background-modifier-border); + border-bottom: 1px solid var(--background-modifier-border); + height: calc(var(--chronoboard-grid-bottom, 0px) + 1px); +} + +.chronoboard-hour-marker-vertical { + min-height: 56px; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 4px 6px 0 6px; + border-top: 1px solid var(--background-modifier-border-hover); + color: var(--text-faint); + font-size: 11px; + height: var(--chronoboard-hour-height, 56px); + box-sizing: border-box; +} + +.chronoboard-week-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + overflow: visible; + border-top: 1px solid var(--background-modifier-border-hover); + border-left: 1px solid var(--background-modifier-border); + border-bottom: 1px solid var(--background-modifier-border); +} + +.chronoboard-week-day-column { + position: relative; + border-left: 1px solid var(--background-modifier-border); + background: + repeating-linear-gradient( + to bottom, + transparent 0, + transparent calc(var(--chronoboard-hour-height, 56px) - 1px), + var(--background-modifier-border-hover) calc(var(--chronoboard-hour-height, 56px) - 1px), + var(--background-modifier-border-hover) var(--chronoboard-hour-height, 56px) + ); +} + +.chronoboard-week-day-column:first-child { + border-left: 0; +} + +.chronoboard-week-day-column:hover, +.chronoboard-day-row:hover { + background-color: color-mix(in srgb, var(--background-modifier-hover) 80%, transparent); +} + +.chronoboard-week-day-column.is-dragging, +.chronoboard-day-row.is-dragging { + background-color: color-mix(in srgb, var(--interactive-accent) 8%, transparent); +} + +.chronoboard-day-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + grid-template-rows: 38px minmax(0, 1fr); + min-height: 0; + flex: 1; +} + +.chronoboard-day-hours-head { + display: grid; + grid-template-columns: repeat(var(--chronoboard-hour-count, 1), var(--chronoboard-day-hour-width, 84px)); + align-items: stretch; + overflow: hidden; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + border-left: 1px solid var(--background-modifier-border); +} + +.chronoboard-hour-marker-horizontal { + min-width: var(--chronoboard-day-hour-width, 84px); + padding: 8px 6px; + border-left: 1px solid var(--background-modifier-border); + color: var(--text-faint); + font-size: 11px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.chronoboard-hour-marker-horizontal:first-child { + border-left: 0; +} + +.chronoboard-day-task-column { + overflow: auto; + border-right: 1px solid var(--background-modifier-border); + padding-top: 8px; + padding-bottom: 0; +} + +.chronoboard-day-grid-column { + overflow: auto; + padding-top: 8px; + padding-bottom: 0; +} + +.chronoboard-day-task-card { + padding: 10px 12px; + margin: 0 8px 8px 8px; + cursor: pointer; + height: 88px; + min-height: 88px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.chronoboard-day-row { + position: relative; + height: 88px; + margin: 0; + min-width: 100%; + border-left: 1px solid var(--background-modifier-border); + background: + repeating-linear-gradient( + to right, + transparent 0, + transparent calc(var(--chronoboard-day-hour-width, 84px) - 1px), + var(--background-modifier-border-hover) calc(var(--chronoboard-day-hour-width, 84px) - 1px), + var(--background-modifier-border-hover) var(--chronoboard-day-hour-width, 84px) + ); +} + +.chronoboard-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.chronoboard-color-picker-wrap { + margin-top: 8px; +} + +.chronoboard-block, +.chronoboard-draft-block { + position: absolute; + left: 6px; + right: 6px; + border-radius: 6px; + padding: 6px 8px 10px 8px; + font-size: 11px; + overflow: hidden; +} + +.chronoboard-block { + background: var(--interactive-accent); + border: 1px solid var(--interactive-accent); + color: var(--text-on-accent); + cursor: pointer; +} + +.chronoboard-block.is-selected-task { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55); +} + +.chronoboard-draft-block { + background: color-mix(in srgb, var(--interactive-accent) 30%, transparent); + border: 1px dashed var(--interactive-accent); + pointer-events: none; +} + +.chronoboard-block-time { + color: rgba(255, 255, 255, 0.82); + font-size: 10px; +} + +.chronoboard-block-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 1px; +} + +.chronoboard-block-status { + font-size: 10px; + font-weight: 600; + white-space: nowrap; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.18); +} + +.chronoboard-block-subtitle { + margin-top: 1px; + font-size: 10px; + font-weight: 500; + opacity: 0.92; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chronoboard-block-notes { + margin-top: 4px; + font-size: 11px; + line-height: 1.25; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.chronoboard-resize-handle { + position: absolute; + background: rgba(255, 255, 255, 0.92); + border-radius: 999px; + opacity: 0; + transition: opacity 120ms ease, background 120ms ease; + pointer-events: none; +} + +.chronoboard-block:hover .chronoboard-resize-handle { + opacity: 1; + pointer-events: auto; +} + +.chronoboard-resize-handle:hover { + background: white; +} + +.chronoboard-resize-handle.is-vertical-start, +.chronoboard-resize-handle.is-vertical-end { + left: 50%; + width: 32px; + height: 4px; + transform: translateX(-50%); + cursor: ns-resize; +} + +.chronoboard-resize-handle.is-vertical-start { + top: 2px; +} + +.chronoboard-resize-handle.is-vertical-end { + bottom: 2px; +} + +.chronoboard-resize-handle.is-horizontal-start, +.chronoboard-resize-handle.is-horizontal-end { + top: 50%; + width: 4px; + height: 28px; + transform: translateY(-50%); + cursor: ew-resize; +} + +.chronoboard-resize-handle.is-horizontal-start { + left: 2px; +} + +.chronoboard-resize-handle.is-horizontal-end { + right: 2px; +} + +.chronoboard-summary-card { + padding: 10px 12px; + cursor: pointer; +} + +.chronoboard-summary-head-total { + font-size: var(--font-ui-small); + font-weight: 700; + color: var(--text-normal); +} + +.chronoboard-center-head { + position: relative; + justify-content: center; +} + +.chronoboard-center-title { + flex: 1; + text-align: center; + font-weight: 700; +} + +.chronoboard-center-title-left { + position: absolute; + left: 12px; + right: auto; + flex: 0 0 auto; + text-align: left; +} + +.chronoboard-center-day-label { + font-weight: 700; + text-align: center; +} + +.chronoboard-center-spacer { + position: absolute; + right: 12px; + width: 1px; + height: 1px; +} + +.chronoboard-setting-color-wrap { + display: inline-flex; + align-items: center; + margin-left: 8px; +} + +.chronoboard-setting-color-picker { + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + background: transparent; + cursor: pointer; + overflow: hidden; +} + +.chronoboard-setting-color-picker::-webkit-color-swatch-wrapper { + padding: 0; +} + +.chronoboard-setting-color-picker::-webkit-color-swatch { + border: 0; + border-radius: 999px; +} + +.chronoboard-month-layout { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.chronoboard-month-head, +.chronoboard-month-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.chronoboard-month-head { + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); +} + +.chronoboard-month-head-cell { + padding: 8px 10px; + text-align: center; + font-size: 11px; + color: var(--text-muted); +} + +.chronoboard-month-grid { + flex: 1; + min-height: 0; + overflow: auto; + grid-auto-rows: minmax(124px, 1fr); +} + +.chronoboard-month-cell { + border-right: 1px solid var(--background-modifier-border); + border-bottom: 1px solid var(--background-modifier-border); + padding: 6px; + min-width: 0; + background: var(--background-primary); + overflow: hidden; +} + +.chronoboard-month-cell.is-outside { + background: color-mix(in srgb, var(--background-secondary) 70%, var(--background-primary)); + color: var(--text-faint); +} + +.chronoboard-month-cell.is-today { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chronoboard-highlight, var(--interactive-accent)) 70%, white); +} + +.chronoboard-month-date { + font-size: 12px; + font-weight: 600; +} + +.chronoboard-month-cell-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.chronoboard-month-total { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; +} + +.chronoboard-month-total.is-light { + color: #d1b443; +} + +.chronoboard-month-total.is-medium { + color: #5d9dff; +} + +.chronoboard-month-total.is-strong { + color: #52d273; +} + +.chronoboard-month-entry-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(34px, 1fr)); + gap: 4px; + margin-top: 8px; + align-content: start; + height: calc(100% - 28px); + min-height: 72px; +} + +.chronoboard-month-entry { + border-radius: 6px; + padding: 5px 6px; + min-height: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + text-align: left; + font-size: 9px; + min-width: 0; + overflow: hidden; + background: var(--interactive-accent); + border: 1px solid var(--interactive-accent); + color: var(--text-on-accent); +} + +.chronoboard-month-entry.is-default-color { + background: var(--interactive-accent); + border-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.chronoboard-month-entry-key { + color: var(--chronoboard-custom-text, inherit); +} + +.chronoboard-month-entry-key { + font-weight: 600; + line-height: 1.15; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; +} + +.chronoboard-month-entry-hours { + color: var(--chronoboard-custom-text, inherit); + opacity: 0.88; + font-size: 9px; + margin-top: 2px; +} + +.chronoboard-month-more { + font-size: 10px; + color: var(--text-muted); + align-self: stretch; + justify-self: stretch; + border-radius: 6px; + border: 1px dashed var(--background-modifier-border); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 6px; + background: var(--background-secondary); +} + +.chronoboard-month-entry-list.count-1 { + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +.chronoboard-month-entry-list.count-1 .slot-1 { + grid-column: 1; + grid-row: 1; +} + +.chronoboard-month-entry-list.count-2 { + grid-template-columns: 1fr; + grid-template-rows: repeat(2, minmax(0, 1fr)); +} + +.chronoboard-month-entry-list.count-2 .slot-1 { + grid-column: 1; + grid-row: 1; +} + +.chronoboard-month-entry-list.count-2 .slot-2 { + grid-column: 1; + grid-row: 2; +} + +.chronoboard-month-entry-list.count-3 .slot-1, +.chronoboard-month-entry-list.count-5 .slot-1 { + grid-column: 1 / -1; + grid-row: 1; +} + +.chronoboard-month-entry-list.count-3 .slot-2, +.chronoboard-month-entry-list.count-5 .slot-2 { + grid-column: 1; + grid-row: 2; +} + +.chronoboard-month-entry-list.count-3 .slot-3, +.chronoboard-month-entry-list.count-5 .slot-3 { + grid-column: 2; + grid-row: 2; +} + +.chronoboard-month-entry-list.count-4 .slot-1, +.chronoboard-month-entry-list.count-5 .slot-1 { + grid-column: 1; + grid-row: 1; +} + +.chronoboard-month-entry-list.count-4 .slot-2, +.chronoboard-month-entry-list.count-5 .slot-2 { + grid-column: 2; + grid-row: 1; +} + +.chronoboard-month-entry-list.count-4 .slot-3, +.chronoboard-month-entry-list.count-5 .slot-3 { + grid-column: 1; + grid-row: 2; +} + +.chronoboard-month-entry-list.count-4 .slot-4, +.chronoboard-month-entry-list.count-5 .slot-4 { + grid-column: 2; + grid-row: 2; +} + +.chronoboard-month-entry-list.count-5 .slot-1 { + grid-column: 1; + grid-row: 1; +} + +.chronoboard-month-entry-list.count-5 .slot-2 { + grid-column: 2; + grid-row: 1; +} + +.chronoboard-month-entry-list.count-5 .slot-3 { + grid-column: 1; + grid-row: 2; +} + +.chronoboard-month-entry-list.count-5 .slot-4 { + grid-column: 2; + grid-row: 2; +} + +.chronoboard-stats-layout { + padding: 16px; + display: grid; + gap: 18px; + overflow: auto; +} + +.chronoboard-stats-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; +} + +.chronoboard-stats-card, +.chronoboard-stats-section { + border: 1px solid var(--background-modifier-border); + border-radius: 10px; + background: var(--background-primary); +} + +.chronoboard-stats-card { + padding: 14px; +} + +.chronoboard-stats-value { + font-size: 28px; + font-weight: 700; + line-height: 1; +} + +.chronoboard-stats-label { + margin-top: 6px; + color: var(--text-muted); + font-size: 11px; +} + +.chronoboard-stats-section { + padding: 14px; +} + +.chronoboard-stats-section-title { + font-size: 12px; + font-weight: 600; + margin-bottom: 12px; +} + +.chronoboard-stats-bars { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); + align-items: end; + gap: 10px; + min-height: 210px; +} + +.chronoboard-stats-bar-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.chronoboard-stats-bar { + width: 100%; + min-height: 10px; + border-radius: 8px 8px 4px 4px; + background: color-mix(in srgb, var(--interactive-accent) 55%, white); +} + +.chronoboard-stats-bar.is-light { + background: #d1b443; +} + +.chronoboard-stats-bar.is-medium { + background: #5d9dff; +} + +.chronoboard-stats-bar.is-strong { + background: #52d273; +} + +.chronoboard-stats-bar-label, +.chronoboard-stats-bar-value { + font-size: 10px; + color: var(--text-muted); +} + +.chronoboard-stats-task-list { + display: grid; + gap: 8px; +} + +.chronoboard-stats-task-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-top: 1px solid var(--background-modifier-border-hover); +} + +.chronoboard-stats-task-row:first-child { + border-top: 0; + padding-top: 0; +} + +.chronoboard-stats-task-name { + min-width: 0; +} + +.chronoboard-stats-task-hours { + font-size: 12px; + font-weight: 700; +} + +.chronoboard-menu-remove-separator { + margin: 6px -8px; + border-top: 1px solid var(--background-modifier-border); +} + +.chronoboard-menu-remove-item .menu-item-title, +.chronoboard-menu-remove-item .menu-item-icon { + color: var(--text-error); +} + +.chronoboard-summary-hours { + font-size: 24px; + font-weight: 700; + line-height: 1.1; + margin: 4px 0; +} + +.chronoboard-modal-list { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.chronoboard-modal-card { + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 10px 12px; + cursor: pointer; + background: var(--background-primary); +} + +.chronoboard-modal-card:hover { + background: var(--background-modifier-hover); +} + +.chronoboard-modal-empty { + color: var(--text-muted); + margin-top: 12px; +} + +@media (max-width: 1200px) { + .chronoboard-layout { + grid-template-columns: 1fr; + } + + .chronoboard-panel + .chronoboard-panel { + border-left: 0; + border-top: 1px solid var(--background-modifier-border); + } +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..2d27897 --- /dev/null +++ b/versions.json @@ -0,0 +1,6 @@ +{ + "0.0.1": "1.5.0", + "0.5.0": "1.5.0", + "0.5.1": "1.5.0", + "0.6.0": "1.5.0" +}