From a265c31460b97c483ef8ba18eb4d0ac45260bbba Mon Sep 17 00:00:00 2001 From: crib Date: Wed, 17 Dec 2025 20:36:13 +0100 Subject: [PATCH] Initial commit --- .gitignore | 30 ++++ build-package.bat | 32 +++++ esbuild.config.mjs | 48 +++++++ main.ts | 173 ++++++++++++++++++++++ manifest.json | 10 ++ package.json | 24 ++++ settings.ts | 51 +++++++ styles.css | 197 +++++++++++++++++++++++++ tsconfig.json | 24 ++++ types.ts | 32 +++++ utils.ts | 62 ++++++++ version-bump.mjs | 12 ++ versions.json | 3 + view.ts | 347 +++++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 1045 insertions(+) create mode 100644 .gitignore create mode 100644 build-package.bat create mode 100644 esbuild.config.mjs create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 package.json create mode 100644 settings.ts create mode 100644 styles.css create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils.ts create mode 100644 version-bump.mjs create mode 100644 versions.json create mode 100644 view.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c56315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ + +# Build output +main.js +main.js.map + +# Package folder +taskweaver-package/ + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.idea/ + +# Data files (plugin saves) +data.json + +# NPM +package-lock.json + +# Claude +.claude + diff --git a/build-package.bat b/build-package.bat new file mode 100644 index 0000000..b0e8f30 --- /dev/null +++ b/build-package.bat @@ -0,0 +1,32 @@ +@echo off +echo Building TaskWeaver plugin... + +REM Run the build +call npm run build + +if %errorlevel% neq 0 ( + echo Build failed! + exit /b %errorlevel% +) + +echo Build successful! +echo Packaging plugin files... + +REM Create package directory +if exist "taskweaver-package" rmdir /s /q "taskweaver-package" +mkdir "taskweaver-package" + +REM Copy required files +copy "main.js" "taskweaver-package\" +copy "manifest.json" "taskweaver-package\" +copy "styles.css" "taskweaver-package\" + +echo. +echo Package created successfully in 'taskweaver-package' folder! +echo. +echo Files included: +echo - main.js +echo - manifest.json +echo - styles.css +echo. +echo To install: Copy the contents of 'taskweaver-package' to your vault's .obsidian/plugins/taskweaver/ folder diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..ad06f8f --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,48 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === "production"); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..0c4a664 --- /dev/null +++ b/main.ts @@ -0,0 +1,173 @@ +import { Plugin } from "obsidian"; +import { TaskWeaverView, VIEW_TYPE_TASKWEAVER } from "./view"; +import { TaskWeaverSettingTab } from "./settings"; +import { TaskWeaverSettings, DEFAULT_SETTINGS, Task, Category } from "./types"; +import { generateUniqueId, logTaskToDailyNote } from "./utils"; + +export default class TaskWeaverPlugin extends Plugin { + settings: TaskWeaverSettings; + + async onload(): Promise { + await this.loadSettings(); + + this.registerView( + VIEW_TYPE_TASKWEAVER, + (leaf) => new TaskWeaverView(leaf, this) + ); + + this.addRibbonIcon("clock", "Open TaskWeaver", () => { + this.activateView(); + }); + + this.addCommand({ + id: "open-taskweaver", + name: "Open TaskWeaver", + callback: () => { + this.activateView(); + } + }); + + this.addSettingTab(new TaskWeaverSettingTab(this.app, this)); + + this.app.workspace.onLayoutReady(() => { + this.activateView(); + }); + } + + async onunload(): Promise { + this.app.workspace.detachLeavesOfType(VIEW_TYPE_TASKWEAVER); + } + + async activateView(): Promise { + const { workspace } = this.app; + + let leaf = workspace.getLeavesOfType(VIEW_TYPE_TASKWEAVER)[0]; + + if (!leaf) { + const rightLeaf = workspace.getRightLeaf(false); + if (rightLeaf) { + await rightLeaf.setViewState({ + type: VIEW_TYPE_TASKWEAVER, + active: true, + }); + leaf = rightLeaf; + } + } + + if (leaf) { + workspace.revealLeaf(leaf); + } + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + this.refreshView(); + } + + refreshView(): void { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_TASKWEAVER); + leaves.forEach(leaf => { + if (leaf.view instanceof TaskWeaverView) { + leaf.view.render(); + } + }); + } + + async addCategory(name: string, color: string, emoji?: string): Promise { + const category: Category = { + id: generateUniqueId(), + name, + color, + emoji + }; + this.settings.categories.push(category); + await this.saveSettings(); + } + + async deleteCategory(categoryId: string): Promise { + const hasTasks = this.settings.tasks.some(t => t.categoryId === categoryId); + if (hasTasks) { + return; + } + + this.settings.categories = this.settings.categories.filter(c => c.id !== categoryId); + await this.saveSettings(); + } + + async addTask(title: string, categoryId: string): Promise { + const task: Task = { + id: generateUniqueId(), + title, + categoryId, + startTime: null, + totalElapsed: 0, + completed: false + }; + this.settings.tasks.push(task); + await this.saveSettings(); + } + + async deleteTask(taskId: string): Promise { + this.settings.tasks = this.settings.tasks.filter(t => t.id !== taskId); + await this.saveSettings(); + } + + async toggleTaskTimer(taskId: string): Promise { + const task = this.settings.tasks.find(t => t.id === taskId); + if (!task) return; + + if (task.startTime) { + const elapsed = Date.now() - task.startTime; + task.totalElapsed += elapsed; + task.startTime = null; + } else { + task.startTime = Date.now(); + } + + await this.saveSettings(); + } + + async toggleTaskComplete(taskId: string): Promise { + const task = this.settings.tasks.find(t => t.id === taskId); + if (!task) return; + + if (!task.completed) { + if (task.startTime) { + const elapsed = Date.now() - task.startTime; + task.totalElapsed += elapsed; + task.startTime = null; + } + + task.completed = true; + task.completedAt = Date.now(); + + if (this.settings.enableDailyNoteLogging) { + const category = this.settings.categories.find(c => c.id === task.categoryId); + await logTaskToDailyNote( + this.app.vault, + task, + category, + this.settings.dailyNoteFormat, + this.settings.dailyNotePath + ); + } + } else { + task.completed = false; + delete task.completedAt; + } + + await this.saveSettings(); + } + + getTaskElapsed(task: Task): number { + let elapsed = task.totalElapsed; + if (task.startTime) { + elapsed += Date.now() - task.startTime; + } + return elapsed; + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e77b3fe --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "taskweaver", + "name": "TaskWeaver", + "version": "1.0.0", + "minAppVersion": "0.15.0", + "description": "A simple task timer with category support and daily note logging", + "author": "", + "authorUrl": "", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aac8794 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "obsidian-taskweaver", + "version": "1.0.0", + "description": "A simple task timer plugin for Obsidian with category support", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "5.29.0", + "@typescript-eslint/parser": "5.29.0", + "builtin-modules": "3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + } +} diff --git a/settings.ts b/settings.ts new file mode 100644 index 0000000..a9540ff --- /dev/null +++ b/settings.ts @@ -0,0 +1,51 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import TaskWeaverPlugin from "./main"; + +export class TaskWeaverSettingTab extends PluginSettingTab { + plugin: TaskWeaverPlugin; + + constructor(app: App, plugin: TaskWeaverPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + new Setting(containerEl) + .setName("Enable daily note logging") + .setDesc("Log completed tasks to your daily note") + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableDailyNoteLogging) + .onChange(async (value) => { + this.plugin.settings.enableDailyNoteLogging = value; + await this.plugin.saveSettings(); + this.display(); + })); + + if (this.plugin.settings.enableDailyNoteLogging) { + new Setting(containerEl) + .setName("Daily note path") + .setDesc("Path to your daily notes folder (e.g., 'Daily Notes' or leave empty for root)") + .addText(text => text + .setPlaceholder("Daily Notes") + .setValue(this.plugin.settings.dailyNotePath) + .onChange(async (value) => { + this.plugin.settings.dailyNotePath = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName("Daily note format") + .setDesc("Format for logging tasks. Available placeholders: {{title}}, {{duration}}, {{emoji}}, {{category}}") + .addTextArea(text => text + .setPlaceholder("- [x] {{title}} ({{duration}}) {{emoji}}") + .setValue(this.plugin.settings.dailyNoteFormat) + .onChange(async (value) => { + this.plugin.settings.dailyNoteFormat = value; + await this.plugin.saveSettings(); + })); + } + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c479135 --- /dev/null +++ b/styles.css @@ -0,0 +1,197 @@ +.taskweaver-container { + padding: var(--size-4-3); + height: 100%; + overflow-y: auto; +} + +.taskweaver-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--size-4-3); + padding-bottom: var(--size-4-2); + border-bottom: 1px solid var(--background-modifier-border); +} + +.taskweaver-header h4 { + margin: 0; + color: var(--text-normal); +} + +.taskweaver-button-group { + display: flex; + gap: var(--size-4-1); +} + +.taskweaver-btn { + padding: var(--size-4-1) var(--size-4-2); + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: var(--radius-s); + cursor: pointer; + font-size: var(--font-ui-small); + transition: background-color 0.2s; +} + +.taskweaver-btn:hover { + background-color: var(--interactive-accent-hover); +} + +.taskweaver-btn-small { + padding: var(--size-2-1) var(--size-4-1); + background-color: var(--background-modifier-error); + color: var(--text-on-accent); + border: none; + border-radius: var(--radius-s); + cursor: pointer; + font-size: var(--font-ui-smaller); + transition: background-color 0.2s; +} + +.taskweaver-btn-small:hover { + background-color: var(--background-modifier-error-hover); +} + +.taskweaver-category { + margin-bottom: var(--size-4-3); + background-color: var(--background-secondary); + border-radius: var(--radius-m); + padding: var(--size-4-2); +} + +.taskweaver-category-header { + padding: var(--size-4-2); + margin-bottom: var(--size-4-2); + border-left: 3px solid var(--interactive-accent); + background-color: var(--background-primary); + border-radius: var(--radius-s); +} + +.taskweaver-category-name { + font-weight: var(--font-semibold); + color: var(--text-normal); + font-size: var(--font-ui-medium); +} + +.taskweaver-emoji { + font-size: var(--font-ui-medium); +} + +.taskweaver-task { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-4-2); + margin-bottom: var(--size-2-2); + background-color: var(--background-primary); + border-radius: var(--radius-s); + border: 1px solid var(--background-modifier-border); + transition: background-color 0.2s; +} + +.taskweaver-task:hover { + background-color: var(--background-primary-alt); +} + +.taskweaver-task-info { + display: flex; + align-items: center; + gap: var(--size-4-2); + flex: 1; +} + +.taskweaver-checkbox { + cursor: pointer; + width: 16px; + height: 16px; +} + +.taskweaver-task-title { + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.taskweaver-task-completed { + text-decoration: line-through; + color: var(--text-muted); +} + +.taskweaver-task-controls { + display: flex; + align-items: center; + gap: var(--size-4-1); +} + +.taskweaver-timer { + font-family: var(--font-monospace); + color: var(--text-muted); + font-size: var(--font-ui-smaller); + min-width: 70px; + text-align: right; +} + +.taskweaver-timer-btn, +.taskweaver-delete-btn { + background: transparent; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + padding: var(--size-2-1) var(--size-2-3); + cursor: pointer; + font-size: var(--font-ui-small); + color: var(--text-normal); + transition: all 0.2s; +} + +.taskweaver-timer-btn:hover { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} + +.taskweaver-delete-btn:hover { + background-color: var(--background-modifier-error); + color: var(--text-on-accent); + border-color: var(--background-modifier-error); +} + +.taskweaver-warning { + color: var(--text-error); + font-size: var(--font-ui-small); + margin: var(--size-4-2) 0; +} + +.taskweaver-categories-list { + margin-bottom: var(--size-4-4); +} + +.taskweaver-category-item { + display: flex; + align-items: center; + gap: var(--size-4-2); + padding: var(--size-4-2); + margin-bottom: var(--size-2-2); + background-color: var(--background-secondary); + border-radius: var(--radius-s); + border: 1px solid var(--background-modifier-border); +} + +.taskweaver-color-preview { + width: 24px; + height: 24px; + border-radius: var(--radius-s); + border: 1px solid var(--background-modifier-border); +} + +.taskweaver-category-info { + flex: 1; + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.modal .taskweaver-warning { + padding: var(--size-4-2); + background-color: var(--background-secondary); + border-radius: var(--radius-s); + border-left: 3px solid var(--text-error); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c44b729 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ] + }, + "include": [ + "**/*.ts" + ] +} diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..90afdda --- /dev/null +++ b/types.ts @@ -0,0 +1,32 @@ +export interface Category { + id: string; + name: string; + color: string; + emoji?: string; +} + +export interface Task { + id: string; + title: string; + categoryId: string; + startTime: number | null; + totalElapsed: number; + completed: boolean; + completedAt?: number; +} + +export interface TaskWeaverSettings { + categories: Category[]; + tasks: Task[]; + enableDailyNoteLogging: boolean; + dailyNoteFormat: string; + dailyNotePath: string; +} + +export const DEFAULT_SETTINGS: TaskWeaverSettings = { + categories: [], + tasks: [], + enableDailyNoteLogging: false, + dailyNoteFormat: "- [x] {{title}} ({{duration}}) {{emoji}}", + dailyNotePath: "" +}; diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..de629d9 --- /dev/null +++ b/utils.ts @@ -0,0 +1,62 @@ +import { TFile, Vault, moment } from "obsidian"; +import { Task, Category } from "./types"; + +export function formatDuration(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } else { + return `${seconds}s`; + } +} + +export function generateUniqueId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +export async function logTaskToDailyNote( + vault: Vault, + task: Task, + category: Category | undefined, + format: string, + dailyNotePath: string +): Promise { + const today = moment().format("YYYY-MM-DD"); + const dailyNoteFileName = `${today}.md`; + + let dailyNoteFolderPath = dailyNotePath.trim(); + if (dailyNoteFolderPath && !dailyNoteFolderPath.endsWith("/")) { + dailyNoteFolderPath += "/"; + } + + const fullPath = `${dailyNoteFolderPath}${dailyNoteFileName}`; + + let file = vault.getAbstractFileByPath(fullPath); + + if (!file || !(file instanceof TFile)) { + const folder = dailyNoteFolderPath.slice(0, -1); + if (folder && !vault.getAbstractFileByPath(folder)) { + await vault.createFolder(folder); + } + file = await vault.create(fullPath, ""); + } + + if (file instanceof TFile) { + const duration = formatDuration(task.totalElapsed); + const logEntry = format + .replace("{{title}}", task.title) + .replace("{{duration}}", duration) + .replace("{{emoji}}", category?.emoji || "") + .replace("{{category}}", category?.name || ""); + + const content = await vault.read(file); + const newContent = content ? `${content}\n${logEntry}` : logEntry; + await vault.modify(file, newContent); + } +} diff --git a/version-bump.mjs b/version-bump.mjs new file mode 100644 index 0000000..7e747f0 --- /dev/null +++ b/version-bump.mjs @@ -0,0 +1,12 @@ +import { readFileSync, writeFileSync } from "fs"; + +const targetVersion = process.env.npm_package_version; + +let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +const { minAppVersion } = manifest; +manifest.version = targetVersion; +writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); + +let versions = JSON.parse(readFileSync("versions.json", "utf8")); +versions[targetVersion] = minAppVersion; +writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..af9a39e --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "0.15.0" +} diff --git a/view.ts b/view.ts new file mode 100644 index 0000000..c757083 --- /dev/null +++ b/view.ts @@ -0,0 +1,347 @@ +import { ItemView, WorkspaceLeaf, Modal, App, Setting } from "obsidian"; +import TaskWeaverPlugin from "./main"; +import { Task, Category } from "./types"; +import { formatDuration, generateUniqueId } from "./utils"; + +export const VIEW_TYPE_TASKWEAVER = "taskweaver-view"; + +export class TaskWeaverView extends ItemView { + plugin: TaskWeaverPlugin; + private updateInterval: number | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: TaskWeaverPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return VIEW_TYPE_TASKWEAVER; + } + + getDisplayText(): string { + return "TaskWeaver"; + } + + getIcon(): string { + return "clock"; + } + + async onOpen(): Promise { + this.render(); + this.updateInterval = window.setInterval(() => { + this.updateTimers(); + }, 1000); + } + + async onClose(): Promise { + if (this.updateInterval !== null) { + window.clearInterval(this.updateInterval); + } + } + + render(): void { + const container = this.containerEl.children[1] as HTMLElement; + container.empty(); + container.addClass("taskweaver-container"); + + const headerDiv = container.createDiv({ cls: "taskweaver-header" }); + headerDiv.createEl("h4", { text: "TaskWeaver" }); + + const buttonContainer = headerDiv.createDiv({ cls: "taskweaver-button-group" }); + + const addTaskBtn = buttonContainer.createEl("button", { + text: "Add Task", + cls: "taskweaver-btn" + }); + addTaskBtn.onclick = () => this.openAddTaskModal(); + + const manageCategoriesBtn = buttonContainer.createEl("button", { + text: "Categories", + cls: "taskweaver-btn" + }); + manageCategoriesBtn.onclick = () => this.openManageCategoriesModal(); + + const categoriesMap = new Map(this.plugin.settings.categories.map(c => [c.id, c])); + + this.plugin.settings.categories.forEach(category => { + const categoryTasks = this.plugin.settings.tasks.filter(t => t.categoryId === category.id && !t.completed); + + if (categoryTasks.length > 0) { + this.renderCategory(container, category, categoryTasks); + } + }); + + const completedTasks = this.plugin.settings.tasks.filter(t => t.completed); + if (completedTasks.length > 0) { + const completedSection = container.createDiv({ cls: "taskweaver-category" }); + const completedHeader = completedSection.createDiv({ cls: "taskweaver-category-header" }); + completedHeader.createEl("span", { text: "✓ Completed", cls: "taskweaver-category-name" }); + + completedTasks.forEach(task => { + const category = categoriesMap.get(task.categoryId); + this.renderTask(completedSection, task, category); + }); + } + } + + renderCategory(container: HTMLElement, category: Category, tasks: Task[]): void { + const categoryDiv = container.createDiv({ cls: "taskweaver-category" }); + + const categoryHeader = categoryDiv.createDiv({ cls: "taskweaver-category-header" }); + categoryHeader.style.borderLeftColor = category.color; + + const categoryName = categoryHeader.createSpan({ cls: "taskweaver-category-name" }); + if (category.emoji) { + categoryName.createSpan({ text: `${category.emoji} `, cls: "taskweaver-emoji" }); + } + categoryName.createSpan({ text: category.name }); + + tasks.forEach(task => { + this.renderTask(categoryDiv, task, category); + }); + } + + renderTask(container: HTMLElement, task: Task, category?: Category): void { + const taskDiv = container.createDiv({ cls: "taskweaver-task" }); + + const taskInfo = taskDiv.createDiv({ cls: "taskweaver-task-info" }); + + const checkbox = taskInfo.createEl("input", { type: "checkbox" }); + checkbox.checked = task.completed; + checkbox.addClass("taskweaver-checkbox"); + checkbox.onclick = async () => { + await this.plugin.toggleTaskComplete(task.id); + }; + + const taskTitle = taskInfo.createSpan({ + text: task.title, + cls: task.completed ? "taskweaver-task-title taskweaver-task-completed" : "taskweaver-task-title" + }); + + const taskControls = taskDiv.createDiv({ cls: "taskweaver-task-controls" }); + + const timerSpan = taskControls.createSpan({ + cls: "taskweaver-timer", + attr: { "data-task-id": task.id } + }); + timerSpan.setText(formatDuration(this.plugin.getTaskElapsed(task))); + + if (!task.completed) { + const timerBtn = taskControls.createEl("button", { + text: task.startTime ? "⏸" : "▶", + cls: "taskweaver-timer-btn" + }); + timerBtn.onclick = async () => { + await this.plugin.toggleTaskTimer(task.id); + }; + + const deleteBtn = taskControls.createEl("button", { + text: "🗑", + cls: "taskweaver-delete-btn" + }); + deleteBtn.onclick = async () => { + await this.plugin.deleteTask(task.id); + }; + } + } + + updateTimers(): void { + const container = this.containerEl.children[1] as HTMLElement; + const timerElements = container.querySelectorAll(".taskweaver-timer"); + + timerElements.forEach((element) => { + const taskId = element.getAttribute("data-task-id"); + if (taskId) { + const task = this.plugin.settings.tasks.find(t => t.id === taskId); + if (task) { + element.setText(formatDuration(this.plugin.getTaskElapsed(task))); + } + } + }); + } + + openAddTaskModal(): void { + new AddTaskModal(this.app, this.plugin, (title, categoryId) => { + this.plugin.addTask(title, categoryId); + }).open(); + } + + openManageCategoriesModal(): void { + new ManageCategoriesModal(this.app, this.plugin).open(); + } +} + +class AddTaskModal extends Modal { + plugin: TaskWeaverPlugin; + onSubmit: (title: string, categoryId: string) => void; + + constructor(app: App, plugin: TaskWeaverPlugin, onSubmit: (title: string, categoryId: string) => void) { + super(app); + this.plugin = plugin; + this.onSubmit = onSubmit; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h3", { text: "Add New Task" }); + + let taskTitle = ""; + let selectedCategoryId = this.plugin.settings.categories[0]?.id || ""; + + new Setting(contentEl) + .setName("Task title") + .addText(text => text + .setPlaceholder("Enter task title") + .onChange(value => { + taskTitle = value; + })); + + if (this.plugin.settings.categories.length === 0) { + contentEl.createEl("p", { + text: "Please create a category first", + cls: "taskweaver-warning" + }); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText("Create Category") + .setCta() + .onClick(() => { + this.close(); + new ManageCategoriesModal(this.app, this.plugin).open(); + })); + } else { + new Setting(contentEl) + .setName("Category") + .addDropdown(dropdown => { + this.plugin.settings.categories.forEach(cat => { + const label = cat.emoji ? `${cat.emoji} ${cat.name}` : cat.name; + dropdown.addOption(cat.id, label); + }); + dropdown.setValue(selectedCategoryId); + dropdown.onChange(value => { + selectedCategoryId = value; + }); + }); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText("Cancel") + .onClick(() => { + this.close(); + })) + .addButton(btn => btn + .setButtonText("Add") + .setCta() + .onClick(() => { + if (taskTitle && selectedCategoryId) { + this.onSubmit(taskTitle, selectedCategoryId); + this.close(); + } + })); + } + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} + +class ManageCategoriesModal extends Modal { + plugin: TaskWeaverPlugin; + + constructor(app: App, plugin: TaskWeaverPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen(): void { + this.render(); + } + + render(): void { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h3", { text: "Manage Categories" }); + + const categoriesList = contentEl.createDiv({ cls: "taskweaver-categories-list" }); + + this.plugin.settings.categories.forEach(category => { + const categoryItem = categoriesList.createDiv({ cls: "taskweaver-category-item" }); + + const colorPreview = categoryItem.createDiv({ cls: "taskweaver-color-preview" }); + colorPreview.style.backgroundColor = category.color; + + const categoryInfo = categoryItem.createDiv({ cls: "taskweaver-category-info" }); + if (category.emoji) { + categoryInfo.createSpan({ text: `${category.emoji} `, cls: "taskweaver-emoji" }); + } + categoryInfo.createSpan({ text: category.name }); + + const deleteBtn = categoryItem.createEl("button", { + text: "Delete", + cls: "taskweaver-btn-small" + }); + deleteBtn.onclick = async () => { + await this.plugin.deleteCategory(category.id); + this.render(); + }; + }); + + contentEl.createEl("h4", { text: "Add New Category" }); + + let categoryName = ""; + let categoryColor = "#4a9eff"; + let categoryEmoji = ""; + + new Setting(contentEl) + .setName("Category name") + .addText(text => text + .setPlaceholder("e.g., Work, Personal") + .onChange(value => { + categoryName = value; + })); + + new Setting(contentEl) + .setName("Color") + .addText(text => text + .setValue(categoryColor) + .setPlaceholder("#4a9eff") + .onChange(value => { + categoryColor = value; + })); + + new Setting(contentEl) + .setName("Emoji (optional)") + .addText(text => text + .setPlaceholder("💼") + .onChange(value => { + categoryEmoji = value; + })); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText("Close") + .onClick(() => { + this.close(); + })) + .addButton(btn => btn + .setButtonText("Add Category") + .setCta() + .onClick(async () => { + if (categoryName) { + await this.plugin.addCategory(categoryName, categoryColor, categoryEmoji); + this.render(); + } + })); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +}