diff --git a/src/canvas-preview.ts b/src/canvas-preview.ts new file mode 100644 index 0000000..f4b035f --- /dev/null +++ b/src/canvas-preview.ts @@ -0,0 +1,15 @@ +/** + * Canvas preview helper + * Opens generated canvas files and shows a confirmation notice + */ + +import { App, Notice } from "obsidian"; + +export async function openGeneratedCanvasPreview( + app: App, + canvasPath: string +): Promise { + await app.workspace.openLinkText(canvasPath, "", "tab"); + const filename = canvasPath.split("/").pop() ?? canvasPath; + new Notice(`Canvas generated: ${filename} — opened in new tab`); +} diff --git a/src/canvas-templates.ts b/src/canvas-templates.ts new file mode 100644 index 0000000..9589901 --- /dev/null +++ b/src/canvas-templates.ts @@ -0,0 +1,590 @@ +/** + * ClawVault Canvas Templates + * Built-in JSON Canvas templates for dashboard generation + */ + +import type { TFile } from "obsidian"; +import { + CANVAS_TEMPLATE_IDS, + type CanvasTemplateId, + TASK_PRIORITY, + TASK_STATUS, +} from "./constants"; +import type { GraphEdge, GraphIndex, GraphNode, ParsedTask, VaultStats } from "./vault-reader"; + +export interface CanvasNodeBase { + id: string; + type: "text" | "file" | "group"; + x: number; + y: number; + width: number; + height: number; + color?: string; +} + +export interface CanvasTextNode extends CanvasNodeBase { + type: "text"; + text: string; +} + +export interface CanvasFileNode extends CanvasNodeBase { + type: "file"; + file: string; +} + +export interface CanvasGroupNode extends CanvasNodeBase { + type: "group"; + label: string; +} + +export type CanvasNode = CanvasTextNode | CanvasFileNode | CanvasGroupNode; + +export interface CanvasEdge { + id: string; + fromNode: string; + toNode: string; + fromSide?: "top" | "right" | "bottom" | "left"; + toSide?: "top" | "right" | "bottom" | "left"; + label?: string; + color?: string; +} + +export interface CanvasData { + nodes: CanvasNode[]; + edges: CanvasEdge[]; +} + +export interface TemplateOptions { + project?: string; + dateRangeDays?: number; + tasks?: ParsedTask[]; + graphIndex?: GraphIndex | null; + vaultName?: string; + allFiles?: TFile[]; + decisionFiles?: TFile[]; + openLoops?: ParsedTask[]; + stats?: VaultStats | null; +} + +export interface CanvasTemplateDefinition { + id: CanvasTemplateId; + title: string; + description: string; +} + +const PRIORITY_NODE_COLORS: Record = { + [TASK_PRIORITY.CRITICAL]: "3", // red + [TASK_PRIORITY.HIGH]: "2", // orange + [TASK_PRIORITY.MEDIUM]: "6", // blue + [TASK_PRIORITY.LOW]: "5", // gray +}; + +export const BUILTIN_CANVAS_TEMPLATES: CanvasTemplateDefinition[] = [ + { + id: CANVAS_TEMPLATE_IDS.PROJECT_BOARD, + title: "Project board", + description: "Kanban-style project board with Backlog, Active, Blocked, and Done columns.", + }, + { + id: CANVAS_TEMPLATE_IDS.BRAIN_OVERVIEW, + title: "Brain overview", + description: + "Radial vault map with grouped entities and links based on the graph index.", + }, + { + id: CANVAS_TEMPLATE_IDS.SPRINT_DASHBOARD, + title: "Sprint dashboard", + description: + "Operational dashboard for active work, decisions, open loops, and graph health stats.", + }, +]; + +class CanvasBuilder { + private nodeSeq = 0; + private edgeSeq = 0; + nodes: CanvasNode[] = []; + edges: CanvasEdge[] = []; + + addTextNode(args: Omit): string { + const id = `text-${++this.nodeSeq}`; + this.nodes.push({ + id, + type: "text", + ...args, + }); + return id; + } + + addFileNode(args: Omit): string { + const id = `file-${++this.nodeSeq}`; + this.nodes.push({ + id, + type: "file", + ...args, + }); + return id; + } + + addGroupNode(args: Omit): string { + const id = `group-${++this.nodeSeq}`; + this.nodes.push({ + id, + type: "group", + ...args, + }); + return id; + } + + addEdge(args: Omit): string { + const id = `edge-${++this.edgeSeq}`; + this.edges.push({ + id, + ...args, + }); + return id; + } + + build(): CanvasData { + return { + nodes: this.nodes, + edges: this.edges, + }; + } +} + +export function generateCanvasTemplate( + templateId: CanvasTemplateId, + vaultPath: string, + options: TemplateOptions +): CanvasData { + switch (templateId) { + case CANVAS_TEMPLATE_IDS.PROJECT_BOARD: + return generateProjectBoardTemplate(vaultPath, options); + case CANVAS_TEMPLATE_IDS.BRAIN_OVERVIEW: + return generateBrainOverviewTemplate(vaultPath, options); + case CANVAS_TEMPLATE_IDS.SPRINT_DASHBOARD: + return generateSprintDashboardTemplate(vaultPath, options); + default: { + const exhaustiveCheck: never = templateId; + throw new Error(`Unsupported template: ${String(exhaustiveCheck)}`); + } + } +} + +export function generateProjectBoardTemplate( + _vaultPath: string, + options: TemplateOptions +): CanvasData { + const builder = new CanvasBuilder(); + const selectedProject = (options.project ?? "").trim().toLowerCase(); + const tasks = (options.tasks ?? []).filter((task) => { + if (!selectedProject) { + return true; + } + return task.frontmatter.project?.trim().toLowerCase() === selectedProject; + }); + + builder.addTextNode({ + x: 0, + y: -180, + width: 1320, + height: 100, + text: selectedProject + ? `# Project board: ${selectedProject}` + : "# Project board: all projects", + }); + + const columns: Array<{ + title: string; + statuses: string[]; + color: string; + }> = [ + { title: "Backlog", statuses: [TASK_STATUS.OPEN], color: "5" }, + { title: "Active", statuses: [TASK_STATUS.IN_PROGRESS], color: "6" }, + { title: "Blocked", statuses: [TASK_STATUS.BLOCKED], color: "3" }, + { title: "Done", statuses: [TASK_STATUS.DONE], color: "4" }, + ]; + + const columnWidth = 310; + const columnGap = 24; + const baseY = 0; + const cardHeight = 120; + + columns.forEach((column, columnIndex) => { + const x = columnIndex * (columnWidth + columnGap); + const columnTasks = tasks + .filter((task) => column.statuses.includes(task.status)) + .sort((a, b) => b.file.stat.mtime - a.file.stat.mtime); + const columnHeight = Math.max(440, columnTasks.length * (cardHeight + 16) + 90); + + builder.addGroupNode({ + label: column.title, + x, + y: baseY, + width: columnWidth, + height: columnHeight, + color: column.color, + }); + + if (columnTasks.length === 0) { + builder.addTextNode({ + x: x + 20, + y: baseY + 70, + width: columnWidth - 40, + height: 80, + text: "No tasks", + color: "5", + }); + return; + } + + columnTasks.forEach((task, taskIndex) => { + const nodeColor = + PRIORITY_NODE_COLORS[`${task.frontmatter.priority ?? TASK_PRIORITY.MEDIUM}`] ?? + PRIORITY_NODE_COLORS[TASK_PRIORITY.MEDIUM]; + builder.addFileNode({ + x: x + 15, + y: baseY + 56 + taskIndex * (cardHeight + 12), + width: columnWidth - 30, + height: cardHeight, + file: task.file.path, + color: nodeColor, + }); + }); + }); + + return builder.build(); +} + +export function generateBrainOverviewTemplate( + _vaultPath: string, + options: TemplateOptions +): CanvasData { + const builder = new CanvasBuilder(); + const graphIndex = options.graphIndex; + const vaultName = options.vaultName ?? "Vault"; + + const centerNodeId = builder.addTextNode({ + x: 680, + y: 420, + width: 360, + height: 140, + text: `# ${vaultName}\n\nBrain overview`, + color: "6", + }); + + if (!graphIndex || graphIndex.nodes.length === 0) { + builder.addTextNode({ + x: 640, + y: 600, + width: 420, + height: 120, + text: "No graph index found. Run ClawVault indexing, then regenerate this canvas.", + color: "5", + }); + return builder.build(); + } + + const degreeByNode = computeNodeDegrees(graphIndex.edges); + const allFilesByPath = new Set((options.allFiles ?? []).map((file) => file.path)); + const filesByBasename = new Map(); + for (const file of options.allFiles ?? []) { + if (!filesByBasename.has(file.basename.toLowerCase())) { + filesByBasename.set(file.basename.toLowerCase(), file.path); + } + } + + const grouped = groupGraphNodes(graphIndex.nodes); + const categories = Array.from(grouped.keys()).sort((a, b) => a.localeCompare(b)); + const radius = 620; + const groupNodeByGraphId = new Map(); + + categories.forEach((category, index) => { + const categoryNodes = grouped.get(category) ?? []; + const angle = (Math.PI * 2 * index) / Math.max(categories.length, 1); + const groupX = Math.round(840 + Math.cos(angle) * radius); + const groupY = Math.round(480 + Math.sin(angle) * radius); + + builder.addGroupNode({ + label: category, + x: groupX - 200, + y: groupY - 160, + width: 400, + height: 340, + color: "5", + }); + + builder.addEdge({ + fromNode: centerNodeId, + toNode: builder.addTextNode({ + x: groupX - 80, + y: groupY - 120, + width: 160, + height: 46, + text: category, + color: "5", + }), + color: "5", + }); + + const topEntities = categoryNodes + .sort((a, b) => { + const degreeA = degreeByNode.get(a.id) ?? 0; + const degreeB = degreeByNode.get(b.id) ?? 0; + return degreeB - degreeA; + }) + .slice(0, 6); + + topEntities.forEach((node, nodeIndex) => { + const filePath = resolveGraphNodePath(node, allFilesByPath, filesByBasename); + const x = groupX - 180 + (nodeIndex % 2) * 190; + const y = groupY - 70 + Math.floor(nodeIndex / 2) * 92; + + let canvasNodeId: string; + if (filePath) { + canvasNodeId = builder.addFileNode({ + x, + y, + width: 170, + height: 78, + file: filePath, + color: "4", + }); + } else { + canvasNodeId = builder.addTextNode({ + x, + y, + width: 170, + height: 78, + text: node.label, + color: "4", + }); + } + groupNodeByGraphId.set(node.id, canvasNodeId); + }); + }); + + let edgeCount = 0; + for (const edge of graphIndex.edges) { + const fromNode = groupNodeByGraphId.get(edge.source); + const toNode = groupNodeByGraphId.get(edge.target); + if (!fromNode || !toNode) { + continue; + } + builder.addEdge({ + fromNode, + toNode, + color: "2", + }); + edgeCount++; + if (edgeCount >= 70) { + break; + } + } + + return builder.build(); +} + +export function generateSprintDashboardTemplate( + _vaultPath: string, + options: TemplateOptions +): CanvasData { + const builder = new CanvasBuilder(); + const tasks = options.tasks ?? []; + const stats = options.stats; + const days = options.dateRangeDays ?? 7; + const activeCount = + stats?.tasks.active ?? tasks.filter((task) => task.status === TASK_STATUS.IN_PROGRESS).length; + const blockedCount = + stats?.tasks.blocked ?? tasks.filter((task) => task.status === TASK_STATUS.BLOCKED).length; + const totalCount = stats?.tasks.total ?? tasks.length; + const doneCount = + stats?.tasks.completed ?? tasks.filter((task) => task.status === TASK_STATUS.DONE).length; + const completionRate = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0; + + builder.addTextNode({ + x: 0, + y: -170, + width: 1360, + height: 90, + text: `# Sprint dashboard (${days}d window)`, + }); + + const topCards = [ + { + title: "Active tasks", + value: `${activeCount}`, + x: 0, + color: "6", + }, + { + title: "Blocked tasks", + value: `${blockedCount}`, + x: 290, + color: "3", + }, + { + title: "Completion rate", + value: `${completionRate}%`, + x: 580, + color: "4", + }, + ]; + + for (const card of topCards) { + builder.addTextNode({ + x: card.x, + y: 0, + width: 260, + height: 120, + color: card.color, + text: `## ${card.title}\n\n${card.value}`, + }); + } + + builder.addGroupNode({ + label: "Recent decisions", + x: 0, + y: 170, + width: 840, + height: 430, + color: "2", + }); + + const recentDecisions = options.decisionFiles ?? []; + if (recentDecisions.length === 0) { + builder.addTextNode({ + x: 24, + y: 240, + width: 790, + height: 80, + text: "No recent decisions in the selected date window.", + color: "5", + }); + } else { + recentDecisions.slice(0, 8).forEach((file, index) => { + builder.addFileNode({ + x: 24 + (index % 2) * 400, + y: 220 + Math.floor(index / 2) * 88, + width: 360, + height: 70, + file: file.path, + color: "2", + }); + }); + } + + builder.addGroupNode({ + label: "Open loops", + x: 0, + y: 640, + width: 840, + height: 320, + color: "3", + }); + + const openLoops = options.openLoops ?? []; + if (openLoops.length === 0) { + builder.addTextNode({ + x: 24, + y: 700, + width: 790, + height: 90, + text: "No open loops older than 7 days.", + color: "4", + }); + } else { + openLoops.slice(0, 6).forEach((task, index) => { + builder.addFileNode({ + x: 24 + (index % 2) * 400, + y: 690 + Math.floor(index / 2) * 84, + width: 360, + height: 66, + file: task.file.path, + color: "3", + }); + }); + } + + builder.addGroupNode({ + label: "Graph stats", + x: 900, + y: 170, + width: 440, + height: 790, + color: "6", + }); + + builder.addTextNode({ + x: 930, + y: 230, + width: 380, + height: 220, + text: + `## Graph\n\nNodes: ${(stats?.nodeCount ?? 0).toLocaleString()}\n` + + `Edges: ${(stats?.edgeCount ?? 0).toLocaleString()}\n` + + `Files: ${(stats?.fileCount ?? 0).toLocaleString()}`, + color: "6", + }); + + builder.addTextNode({ + x: 930, + y: 490, + width: 380, + height: 220, + text: + `## Tasks\n\nOpen: ${stats?.tasks.open ?? 0}\n` + + `In progress: ${stats?.tasks.active ?? 0}\n` + + `Blocked: ${stats?.tasks.blocked ?? 0}\n` + + `Done: ${stats?.tasks.completed ?? 0}`, + color: "4", + }); + + return builder.build(); +} + +function groupGraphNodes(nodes: GraphNode[]): Map { + const grouped = new Map(); + for (const node of nodes) { + const rawCategory = node.category ?? node.type ?? "uncategorized"; + const category = rawCategory.trim().toLowerCase(); + if (!grouped.has(category)) { + grouped.set(category, []); + } + grouped.get(category)?.push(node); + } + return grouped; +} + +function computeNodeDegrees(edges: GraphEdge[]): Map { + const degrees = new Map(); + for (const edge of edges) { + degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1); + degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1); + } + return degrees; +} + +function resolveGraphNodePath( + node: GraphNode, + allFilesByPath: Set, + filesByBasename: Map +): string | null { + const id = node.id.trim(); + const label = node.label.trim(); + + if (allFilesByPath.has(id)) { + return id; + } + + if (id.endsWith(".md") && allFilesByPath.has(id)) { + return id; + } + + const inferredFromLabel = filesByBasename.get(label.toLowerCase()); + if (inferredFromLabel) { + return inferredFromLabel; + } + + return null; +} diff --git a/src/commands.ts b/src/commands.ts index 4fa1a12..f161d07 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,10 +3,19 @@ * Command palette registrations */ -import { Notice, Platform, TFile } from "obsidian"; +import { Notice, Platform, TFile, TFolder } from "obsidian"; import type ClawVaultPlugin from "./main"; -import { COMMAND_IDS, STATUS_VIEW_TYPE } from "./constants"; -import { CaptureModal, TaskModal, BlockedModal } from "./modals"; +import { generateCanvasTemplate } from "./canvas-templates"; +import { openGeneratedCanvasPreview } from "./canvas-preview"; +import { COMMAND_IDS, STATUS_VIEW_TYPE, TASK_BOARD_VIEW_TYPE } from "./constants"; +import { + BlockedModal, + CaptureModal, + OpenLoopsModal, + TaskModal, + TemplateModal, +} from "./modals"; +import type { TemplateModalResult } from "./modals/template-modal"; /** * Register all ClawVault commands @@ -15,7 +24,7 @@ export function registerCommands(plugin: ClawVaultPlugin): void { // Generate Dashboard command plugin.addCommand({ id: COMMAND_IDS.GENERATE_DASHBOARD, - name: "Generate dashboard", + name: "ClawVault: Generate Dashboard", callback: () => { void generateDashboard(plugin); }, @@ -24,7 +33,8 @@ export function registerCommands(plugin: ClawVaultPlugin): void { // Quick Capture command plugin.addCommand({ id: COMMAND_IDS.QUICK_CAPTURE, - name: "Quick capture", + name: "ClawVault: Quick Capture", + hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: "c" }], callback: () => { new CaptureModal(plugin.app).open(); }, @@ -33,7 +43,8 @@ export function registerCommands(plugin: ClawVaultPlugin): void { // Add Task command plugin.addCommand({ id: COMMAND_IDS.ADD_TASK, - name: "Add task", + name: "ClawVault: Add Task", + hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: "t" }], callback: () => { new TaskModal(plugin.app).open(); }, @@ -42,7 +53,7 @@ export function registerCommands(plugin: ClawVaultPlugin): void { // View Blocked command plugin.addCommand({ id: COMMAND_IDS.VIEW_BLOCKED, - name: "View blocked tasks", + name: "ClawVault: View Blocked Tasks", callback: () => { new BlockedModal(plugin.app, plugin.vaultReader).open(); }, @@ -51,11 +62,49 @@ export function registerCommands(plugin: ClawVaultPlugin): void { // Open Status Panel command plugin.addCommand({ id: COMMAND_IDS.OPEN_STATUS_PANEL, - name: "Open status panel", + name: "ClawVault: Open Status Panel", callback: () => { void activateStatusView(plugin); }, }); + + // Open Task Board command + plugin.addCommand({ + id: COMMAND_IDS.OPEN_TASK_BOARD, + name: "ClawVault: Open Task Board", + callback: () => { + void activateTaskBoardView(plugin); + }, + }); + + // Generate Canvas from Template command + plugin.addCommand({ + id: COMMAND_IDS.GENERATE_CANVAS_FROM_TEMPLATE, + name: "ClawVault: Generate Canvas from Template", + callback: () => { + new TemplateModal(plugin.app, async (result) => { + await generateCanvasFromTemplate(plugin, result); + }).open(); + }, + }); + + // Force Refresh Stats command + plugin.addCommand({ + id: COMMAND_IDS.REFRESH_STATS, + name: "ClawVault: Refresh Stats", + callback: () => { + void plugin.refreshAll(); + }, + }); + + // Show Open Loops command + plugin.addCommand({ + id: COMMAND_IDS.SHOW_OPEN_LOOPS, + name: "ClawVault: Show Open Loops", + callback: () => { + new OpenLoopsModal(plugin.app, plugin.vaultReader).open(); + }, + }); } /** @@ -123,6 +172,64 @@ async function generateDashboard(plugin: ClawVaultPlugin): Promise { } } +async function generateCanvasFromTemplate( + plugin: ClawVaultPlugin, + result: TemplateModalResult +): Promise { + new Notice("Generating canvas..."); + + try { + const tasks = await plugin.vaultReader.getAllTasks(); + const graphIndex = await plugin.vaultReader.readGraphIndex(); + const stats = await plugin.vaultReader.getVaultStats(); + const openLoops = await plugin.vaultReader.getOpenLoops(7); + const decisionFiles = plugin.vaultReader.getRecentDecisionFiles(result.dateRangeDays, 12); + const allFiles = plugin.app.vault.getMarkdownFiles(); + const vaultPath = + plugin.settings.vaultPathOverride || + (plugin.app.vault.adapter as { basePath?: string }).basePath || + plugin.app.vault.getName(); + + const canvasData = generateCanvasTemplate(result.templateId, vaultPath, { + project: result.projectFilter, + dateRangeDays: result.dateRangeDays, + tasks, + graphIndex, + vaultName: stats.vaultName, + allFiles, + decisionFiles, + openLoops, + stats, + }); + + const dashboardsFolder = "dashboards"; + await ensureFolderExists(plugin, dashboardsFolder); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filePath = `${dashboardsFolder}/${result.templateId}-${timestamp}.canvas`; + await plugin.app.vault.create(filePath, JSON.stringify(canvasData, null, 2)); + + await openGeneratedCanvasPreview(plugin.app, filePath); + } catch (error) { + console.error("ClawVault: Canvas generation failed", error); + new Notice( + error instanceof Error + ? `Canvas generation failed: ${error.message}` + : "Canvas generation failed." + ); + } +} + +async function ensureFolderExists(plugin: ClawVaultPlugin, folderPath: string): Promise { + const existing = plugin.app.vault.getAbstractFileByPath(folderPath); + if (existing instanceof TFolder) { + return; + } + if (existing instanceof TFile) { + throw new Error(`Cannot create folder "${folderPath}" because a file already exists.`); + } + await plugin.app.vault.createFolder(folderPath); +} + /** * Activate the status view in the right sidebar */ @@ -149,3 +256,23 @@ async function activateStatusView(plugin: ClawVaultPlugin): Promise { await workspace.revealLeaf(leaf); } } + +async function activateTaskBoardView(plugin: ClawVaultPlugin): Promise { + const { workspace } = plugin.app; + let leaf = workspace.getLeavesOfType(TASK_BOARD_VIEW_TYPE)[0]; + + if (!leaf) { + const rightLeaf = workspace.getRightLeaf(false); + if (rightLeaf) { + await rightLeaf.setViewState({ + type: TASK_BOARD_VIEW_TYPE, + active: true, + }); + leaf = rightLeaf; + } + } + + if (leaf) { + await workspace.revealLeaf(leaf); + } +} diff --git a/src/constants.ts b/src/constants.ts index 4a5b6ce..b489895 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,7 @@ // View type identifier for the status panel export const STATUS_VIEW_TYPE = "clawvault-status-view"; +export const TASK_BOARD_VIEW_TYPE = "clawvault-task-board"; // Default category colors for graph nodes export const DEFAULT_CATEGORY_COLORS: Record = { @@ -68,4 +69,17 @@ export const COMMAND_IDS = { ADD_TASK: "clawvault-add-task", VIEW_BLOCKED: "clawvault-view-blocked", OPEN_STATUS_PANEL: "clawvault-open-status-panel", + OPEN_TASK_BOARD: "clawvault-open-task-board", + GENERATE_CANVAS_FROM_TEMPLATE: "clawvault-generate-canvas-from-template", + REFRESH_STATS: "clawvault-refresh-stats", + SHOW_OPEN_LOOPS: "clawvault-show-open-loops", } as const; + +export const CANVAS_TEMPLATE_IDS = { + PROJECT_BOARD: "project-board", + BRAIN_OVERVIEW: "brain-overview", + SPRINT_DASHBOARD: "sprint-dashboard", +} as const; + +export type CanvasTemplateId = + typeof CANVAS_TEMPLATE_IDS[keyof typeof CANVAS_TEMPLATE_IDS]; diff --git a/src/graph-enhancer.ts b/src/graph-enhancer.ts new file mode 100644 index 0000000..a2b94c7 --- /dev/null +++ b/src/graph-enhancer.ts @@ -0,0 +1,206 @@ +/** + * ClawVault Graph Enhancer + * Applies dynamic graph coloring and node sizing based on metadata + */ + +import { TFile } from "obsidian"; +import type ClawVaultPlugin from "./main"; +import { DEFAULT_CATEGORY_COLORS, TASK_PRIORITY } from "./constants"; + +export class GraphEnhancer { + private plugin: ClawVaultPlugin; + private observer: MutationObserver | null = null; + private refreshTimerId: number | null = null; + + constructor(plugin: ClawVaultPlugin) { + this.plugin = plugin; + } + + initialize(): void { + this.applyCategoryVariables(); + this.setupObserver(); + this.registerEventHandlers(); + this.scheduleEnhance(); + } + + cleanup(): void { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + if (this.refreshTimerId !== null) { + window.clearTimeout(this.refreshTimerId); + this.refreshTimerId = null; + } + } + + applyCategoryVariables(): void { + const root = document.documentElement; + const mergedColors = Object.assign( + {}, + DEFAULT_CATEGORY_COLORS, + this.plugin.settings.categoryColors + ); + + for (const [category, color] of Object.entries(mergedColors)) { + root.style.setProperty(`--clawvault-color-${category}`, color); + } + } + + scheduleEnhance(delayMs = 120): void { + if (this.refreshTimerId !== null) { + window.clearTimeout(this.refreshTimerId); + } + this.refreshTimerId = window.setTimeout(() => { + this.refreshTimerId = null; + this.enhanceOpenGraphViews(); + }, delayMs); + } + + private setupObserver(): void { + this.observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.type === "childList" && + (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) + ) { + this.scheduleEnhance(80); + break; + } + } + }); + + this.observer.observe(document.body, { + childList: true, + subtree: true, + }); + } + + private registerEventHandlers(): void { + this.plugin.registerEvent( + this.plugin.app.workspace.on("layout-change", () => { + this.scheduleEnhance(60); + }) + ); + + this.plugin.registerEvent( + this.plugin.app.workspace.on("active-leaf-change", () => { + this.scheduleEnhance(60); + }) + ); + + this.plugin.registerEvent( + this.plugin.app.metadataCache.on("resolved", () => { + this.scheduleEnhance(90); + }) + ); + + this.plugin.registerEvent( + this.plugin.app.vault.on("modify", (file) => { + if (file instanceof TFile && file.extension === "md") { + this.scheduleEnhance(); + } + }) + ); + } + + private enhanceOpenGraphViews(): void { + this.applyCategoryVariables(); + const graphNodes = document.querySelectorAll(".graph-view .node"); + for (const graphNode of Array.from(graphNodes)) { + this.applyMetadataDecoration(graphNode); + } + } + + private applyMetadataDecoration(graphNode: HTMLElement): void { + const path = graphNode.dataset.path; + if (!path) { + return; + } + + const abstractFile = this.plugin.app.vault.getAbstractFileByPath(path); + if (!(abstractFile instanceof TFile)) { + return; + } + + const cache = this.plugin.app.metadataCache.getFileCache(abstractFile); + const frontmatter = cache?.frontmatter as Record | undefined; + + const category = this.getCategoryFromPathOrFrontmatter(path, frontmatter); + if (category) { + graphNode.dataset.category = category; + } + + const firstTag = this.getFirstTag(frontmatter); + if (firstTag) { + graphNode.classList.add("clawvault-node-tagged"); + graphNode.dataset.tag = firstTag; + graphNode.style.setProperty("--clawvault-tag-color", this.resolveTagColor(firstTag)); + } else { + graphNode.classList.remove("clawvault-node-tagged"); + graphNode.removeAttribute("data-tag"); + graphNode.style.removeProperty("--clawvault-tag-color"); + } + + const priority = this.getPriority(frontmatter); + const isCritical = priority === TASK_PRIORITY.CRITICAL; + graphNode.classList.toggle("clawvault-node-critical", isCritical); + } + + private getCategoryFromPathOrFrontmatter( + path: string, + frontmatter: Record | undefined + ): string { + const frontmatterCategory = frontmatter?.category; + if (typeof frontmatterCategory === "string" && frontmatterCategory.trim().length > 0) { + return frontmatterCategory.trim().toLowerCase(); + } + return path.split("/")[0]?.toLowerCase() ?? "default"; + } + + private getFirstTag(frontmatter: Record | undefined): string | null { + const rawTags = frontmatter?.tags; + if (Array.isArray(rawTags)) { + for (const tag of rawTags) { + if (typeof tag === "string" && tag.trim().length > 0) { + return tag.replace(/^#/, "").trim().toLowerCase(); + } + } + } + + if (typeof rawTags === "string" && rawTags.trim().length > 0) { + const tags = rawTags + .split(/[,\s]+/) + .map((tag) => tag.replace(/^#/, "").trim().toLowerCase()) + .filter((tag) => tag.length > 0); + if (tags.length > 0) { + return tags[0] ?? null; + } + } + + return null; + } + + private resolveTagColor(tag: string): string { + const categoryColor = this.plugin.settings.categoryColors[tag]; + if (typeof categoryColor === "string" && categoryColor.trim().length > 0) { + return categoryColor; + } + + let hash = 0; + for (let i = 0; i < tag.length; i++) { + hash = (hash << 5) - hash + tag.charCodeAt(i); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue}, 68%, 56%)`; + } + + private getPriority(frontmatter: Record | undefined): string { + const priority = frontmatter?.priority; + if (typeof priority === "string") { + return priority.trim().toLowerCase(); + } + return ""; + } +} diff --git a/src/main.ts b/src/main.ts index 41d27ae..acc402b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,9 +7,15 @@ import { Plugin, WorkspaceLeaf } from "obsidian"; import { ClawVaultSettings, ClawVaultSettingTab, DEFAULT_SETTINGS } from "./settings"; import { VaultReader } from "./vault-reader"; import { ClawVaultStatusView } from "./status-view"; +import { ClawVaultTaskBoardView } from "./task-board-view"; import { FileDecorations } from "./decorations"; +import { GraphEnhancer } from "./graph-enhancer"; import { registerCommands } from "./commands"; -import { STATUS_VIEW_TYPE, DEFAULT_CATEGORY_COLORS } from "./constants"; +import { + DEFAULT_CATEGORY_COLORS, + STATUS_VIEW_TYPE, + TASK_BOARD_VIEW_TYPE, +} from "./constants"; export default class ClawVaultPlugin extends Plugin { settings: ClawVaultSettings = DEFAULT_SETTINGS; @@ -18,6 +24,7 @@ export default class ClawVaultPlugin extends Plugin { private statusBarItem: HTMLElement | null = null; private refreshIntervalId: number | null = null; private fileDecorations: FileDecorations | null = null; + private graphEnhancer: GraphEnhancer | null = null; async onload(): Promise { await this.loadSettings(); @@ -30,6 +37,11 @@ export default class ClawVaultPlugin extends Plugin { return new ClawVaultStatusView(leaf, this); }); + // Register the task board view + this.registerView(TASK_BOARD_VIEW_TYPE, (leaf: WorkspaceLeaf) => { + return new ClawVaultTaskBoardView(leaf, this); + }); + // Add ribbon icon this.addRibbonIcon("database", "ClawVault status", () => { void this.activateStatusView(); @@ -53,6 +65,10 @@ export default class ClawVaultPlugin extends Plugin { this.fileDecorations = new FileDecorations(this); this.fileDecorations.initialize(); + // Initialize graph enhancements + this.graphEnhancer = new GraphEnhancer(this); + this.graphEnhancer.initialize(); + // Start refresh interval this.startRefreshInterval(); @@ -73,6 +89,12 @@ export default class ClawVaultPlugin extends Plugin { this.fileDecorations = null; } + // Clean up graph enhancements + if (this.graphEnhancer) { + this.graphEnhancer.cleanup(); + this.graphEnhancer = null; + } + // Note: Don't detach leaves in onunload per Obsidian guidelines // The view will be properly cleaned up by Obsidian } @@ -117,6 +139,29 @@ export default class ClawVaultPlugin extends Plugin { } } + /** + * Activate the task board view in the right sidebar + */ + async activateTaskBoardView(): Promise { + const { workspace } = this.app; + let leaf = workspace.getLeavesOfType(TASK_BOARD_VIEW_TYPE)[0]; + + if (!leaf) { + const rightLeaf = workspace.getRightLeaf(false); + if (rightLeaf) { + await rightLeaf.setViewState({ + type: TASK_BOARD_VIEW_TYPE, + active: true, + }); + leaf = rightLeaf; + } + } + + if (leaf) { + await workspace.revealLeaf(leaf); + } + } + /** * Update status bar visibility based on settings */ @@ -189,10 +234,22 @@ export default class ClawVaultPlugin extends Plugin { } } + // Refresh task board if open + const boardLeaves = this.app.workspace.getLeavesOfType(TASK_BOARD_VIEW_TYPE); + for (const leaf of boardLeaves) { + const view = leaf.view; + if (view instanceof ClawVaultTaskBoardView) { + await view.refresh(); + } + } + // Update file decorations if (this.fileDecorations) { await this.fileDecorations.decorateAllFiles(); } + + // Update graph enhancements + this.graphEnhancer?.scheduleEnhance(); } /** @@ -200,7 +257,7 @@ export default class ClawVaultPlugin extends Plugin { * Note: Graph coloring is handled via styles.css with CSS custom properties */ updateGraphStyles(): void { - // Graph styles are defined in styles.css using CSS custom properties - // This method is kept for potential future dynamic style updates + this.graphEnhancer?.applyCategoryVariables(); + this.graphEnhancer?.scheduleEnhance(40); } } diff --git a/src/modals/index.ts b/src/modals/index.ts index cb7b623..c1bcd71 100644 --- a/src/modals/index.ts +++ b/src/modals/index.ts @@ -6,3 +6,5 @@ export { CaptureModal } from "./capture-modal"; export { TaskModal } from "./task-modal"; export { BlockedModal } from "./blocked-modal"; +export { TemplateModal } from "./template-modal"; +export { OpenLoopsModal } from "./open-loops-modal"; diff --git a/src/modals/open-loops-modal.ts b/src/modals/open-loops-modal.ts new file mode 100644 index 0000000..9502784 --- /dev/null +++ b/src/modals/open-loops-modal.ts @@ -0,0 +1,93 @@ +/** + * Open Loops Modal + * Lists aging non-completed tasks (> 7 days old) + */ + +import { App, Modal } from "obsidian"; +import type { ParsedTask, VaultReader } from "../vault-reader"; + +export class OpenLoopsModal extends Modal { + private vaultReader: VaultReader; + + constructor(app: App, vaultReader: VaultReader) { + super(app); + this.vaultReader = vaultReader; + } + + async onOpen(): Promise { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("clawvault-open-loops-modal"); + contentEl.createEl("h2", { text: "Open loops" }); + + const loadingEl = contentEl.createDiv({ cls: "clawvault-loading" }); + loadingEl.setText("Loading open loops..."); + + try { + const openLoops = await this.vaultReader.getOpenLoops(7); + loadingEl.remove(); + this.renderOpenLoops(openLoops); + } catch (error) { + loadingEl.remove(); + contentEl.createEl("p", { + text: + error instanceof Error + ? `Failed to load open loops: ${error.message}` + : "Failed to load open loops.", + cls: "clawvault-error-details", + }); + } + } + + onClose(): void { + this.contentEl.empty(); + } + + private renderOpenLoops(tasks: ParsedTask[]): void { + const { contentEl } = this; + + if (tasks.length === 0) { + const emptyState = contentEl.createDiv({ cls: "clawvault-empty-state" }); + emptyState.createEl("p", { text: "No open loops older than 7 days." }); + } else { + const summary = contentEl.createDiv({ cls: "clawvault-blocked-summary" }); + summary.setText( + `${tasks.length} open task${tasks.length === 1 ? "" : "s"} older than 7 days` + ); + + const listEl = contentEl.createDiv({ cls: "clawvault-blocked-list" }); + for (const task of tasks) { + const itemEl = listEl.createDiv({ cls: "clawvault-blocked-item clawvault-open-loop-item" }); + const ageDays = this.getAgeInDays(task.createdAt ?? new Date(task.file.stat.ctime)); + + const titleLink = itemEl.createEl("a", { + text: task.frontmatter.title ?? task.file.basename, + cls: "clawvault-blocked-link", + }); + titleLink.addEventListener("click", (event) => { + event.preventDefault(); + this.close(); + void this.app.workspace.openLinkText(task.file.path, "", "tab"); + }); + + const meta = itemEl.createDiv({ cls: "clawvault-blocked-meta" }); + meta.createSpan({ text: `Status: ${task.status}` }); + if (task.frontmatter.project) { + meta.createSpan({ text: `Project: ${task.frontmatter.project}` }); + } + meta.createSpan({ text: `${ageDays}d open`, cls: "clawvault-open-loop-age" }); + } + } + + const actionsEl = contentEl.createDiv({ cls: "clawvault-modal-buttons" }); + actionsEl.createEl("button", { + text: "Close", + cls: "mod-cta", + }).addEventListener("click", () => this.close()); + } + + private getAgeInDays(createdAt: Date): number { + const diff = Date.now() - createdAt.getTime(); + return Math.max(0, Math.floor(diff / (24 * 60 * 60 * 1000))); + } +} diff --git a/src/modals/template-modal.ts b/src/modals/template-modal.ts new file mode 100644 index 0000000..20595c9 --- /dev/null +++ b/src/modals/template-modal.ts @@ -0,0 +1,123 @@ +/** + * Canvas Template Picker Modal + * Allows users to select a template and runtime options + */ + +import { App, Modal, Setting } from "obsidian"; +import { + CANVAS_TEMPLATE_IDS, + type CanvasTemplateId, +} from "../constants"; +import { BUILTIN_CANVAS_TEMPLATES } from "../canvas-templates"; + +export interface TemplateModalResult { + templateId: CanvasTemplateId; + projectFilter: string; + dateRangeDays: number; +} + +export class TemplateModal extends Modal { + private templateId: CanvasTemplateId = CANVAS_TEMPLATE_IDS.PROJECT_BOARD; + private projectFilter = ""; + private dateRangeDays = 7; + private onSubmit: (result: TemplateModalResult) => Promise | void; + + constructor( + app: App, + onSubmit: (result: TemplateModalResult) => Promise | void + ) { + super(app); + this.onSubmit = onSubmit; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("clawvault-template-modal"); + + contentEl.createEl("h2", { text: "Generate canvas from template" }); + + new Setting(contentEl) + .setName("Template") + .setDesc("Choose a built-in ClawVault canvas template") + .addDropdown((dropdown) => { + for (const template of BUILTIN_CANVAS_TEMPLATES) { + dropdown.addOption(template.id, template.title); + } + dropdown.setValue(this.templateId); + dropdown.onChange((value) => { + this.templateId = value as CanvasTemplateId; + this.renderTemplateDescription(); + }); + }); + + this.renderTemplateDescription(); + + new Setting(contentEl) + .setName("Project filter") + .setDesc("Optional project name filter (used by project board)") + .addText((text) => + text + .setPlaceholder("All projects") + .setValue(this.projectFilter) + .onChange((value) => { + this.projectFilter = value; + }) + ); + + new Setting(contentEl) + .setName("Date range (days)") + .setDesc("Used by sprint dashboard for recent decisions") + .addSlider((slider) => + slider + .setLimits(1, 30, 1) + .setDynamicTooltip() + .setValue(this.dateRangeDays) + .onChange((value) => { + this.dateRangeDays = value; + }) + ); + + const actionsEl = contentEl.createDiv({ cls: "clawvault-modal-buttons" }); + const cancelBtn = actionsEl.createEl("button", { text: "Cancel" }); + cancelBtn.addEventListener("click", () => this.close()); + + const generateBtn = actionsEl.createEl("button", { + text: "Generate canvas", + cls: "mod-cta", + }); + generateBtn.addEventListener("click", () => { + void this.handleSubmit(); + }); + } + + onClose(): void { + this.contentEl.empty(); + } + + private renderTemplateDescription(): void { + const existing = this.contentEl.querySelector(".clawvault-template-description"); + if (existing) { + existing.remove(); + } + + const selectedTemplate = BUILTIN_CANVAS_TEMPLATES.find( + (template) => template.id === this.templateId + ); + const description = this.contentEl.createDiv({ + cls: "clawvault-template-description", + }); + description.setText( + selectedTemplate?.description ?? "Template description unavailable." + ); + } + + private async handleSubmit(): Promise { + await this.onSubmit({ + templateId: this.templateId, + projectFilter: this.projectFilter.trim(), + dateRangeDays: this.dateRangeDays, + }); + this.close(); + } +} diff --git a/src/status-view.ts b/src/status-view.ts index 1d8eeff..a153085 100644 --- a/src/status-view.ts +++ b/src/status-view.ts @@ -5,8 +5,15 @@ import { ItemView, WorkspaceLeaf } from "obsidian"; import type ClawVaultPlugin from "./main"; -import { STATUS_VIEW_TYPE } from "./constants"; -import type { VaultStats } from "./vault-reader"; +import { COMMAND_IDS, STATUS_VIEW_TYPE } from "./constants"; +import type { ObservationSession, ParsedTask, VaultStats } from "./vault-reader"; + +interface StatusViewData { + stats: VaultStats; + backlogItems: ParsedTask[]; + recentSessions: ObservationSession[]; + openLoops: ParsedTask[]; +} /** * Status panel view for the right sidebar @@ -56,8 +63,18 @@ export class ClawVaultStatusView extends ItemView { this.statusContentEl.empty(); try { - const stats = await this.plugin.vaultReader.getVaultStats(); - this.renderStats(stats); + const [stats, backlogItems, recentSessions, openLoops] = await Promise.all([ + this.plugin.vaultReader.getVaultStats(), + this.plugin.vaultReader.getBacklogTasks(5), + this.plugin.vaultReader.getRecentObservationSessions(5), + this.plugin.vaultReader.getOpenLoops(7), + ]); + this.renderStats({ + stats, + backlogItems, + recentSessions, + openLoops, + }); } catch (error) { this.renderError(error); } @@ -66,8 +83,9 @@ export class ClawVaultStatusView extends ItemView { /** * Render vault statistics */ - private renderStats(stats: VaultStats): void { + private renderStats(data: StatusViewData): void { if (!this.statusContentEl) return; + const { stats, backlogItems, recentSessions, openLoops } = data; // Header const header = this.statusContentEl.createDiv({ cls: "clawvault-status-header" }); @@ -115,6 +133,44 @@ export class ClawVaultStatusView extends ItemView { progressFill.style.width = `${completedPct}%`; } + // Backlog section + const backlogSection = this.statusContentEl.createDiv({ cls: "clawvault-status-section" }); + backlogSection.createEl("h4", { + text: `Backlog (${stats.tasks.open})`, + }); + + if (backlogItems.length === 0) { + backlogSection.createDiv({ + text: "No backlog tasks", + cls: "clawvault-empty-hint", + }); + } else { + const backlogList = backlogSection.createDiv({ cls: "clawvault-status-list" }); + for (const task of backlogItems) { + const item = backlogList.createDiv({ cls: "clawvault-status-list-item" }); + const link = item.createEl("a", { + text: task.frontmatter.title ?? task.file.basename, + cls: "clawvault-blocked-link", + }); + link.addEventListener("click", (event) => { + event.preventDefault(); + void this.app.workspace.openLinkText(task.file.path, "", "tab"); + }); + + const meta = item.createDiv({ cls: "clawvault-status-list-meta" }); + if (task.frontmatter.project) { + meta.createSpan({ text: task.frontmatter.project }); + } + if (task.frontmatter.priority) { + if (meta.childElementCount > 0) meta.createSpan({ text: " · " }); + meta.createSpan({ + text: `${task.frontmatter.priority}`, + cls: `clawvault-priority-${task.frontmatter.priority}`, + }); + } + } + } + // Inbox section if (stats.inboxCount > 0) { const inboxSection = this.statusContentEl.createDiv({ cls: "clawvault-status-section" }); @@ -139,6 +195,89 @@ export class ClawVaultStatusView extends ItemView { }); } + // Recent observation sessions + const recentActivitySection = this.statusContentEl.createDiv({ + cls: "clawvault-status-section", + }); + recentActivitySection.createEl("h4", { text: "Recent activity" }); + if (recentSessions.length === 0) { + recentActivitySection.createDiv({ + text: "No observed sessions found.", + cls: "clawvault-empty-hint", + }); + } else { + const sessionsList = recentActivitySection.createDiv({ cls: "clawvault-status-list" }); + for (const session of recentSessions) { + const row = sessionsList.createDiv({ cls: "clawvault-status-list-item" }); + const link = row.createEl("a", { + text: session.file.basename, + cls: "clawvault-blocked-link", + }); + link.addEventListener("click", (event) => { + event.preventDefault(); + void this.app.workspace.openLinkText(session.file.path, "", "tab"); + }); + row.createDiv({ + text: session.timestamp.toLocaleString(), + cls: "clawvault-status-list-meta", + }); + } + } + + // Open loops section + const openLoopsSection = this.statusContentEl.createDiv({ cls: "clawvault-status-section" }); + openLoopsSection.createEl("h4", { + text: `Open loops (${openLoops.length})`, + }); + + if (openLoops.length === 0) { + openLoopsSection.createDiv({ + text: "No open loops older than 7 days.", + cls: "clawvault-empty-hint", + }); + } else { + const loopList = openLoopsSection.createDiv({ cls: "clawvault-status-list" }); + for (const task of openLoops.slice(0, 5)) { + const row = loopList.createDiv({ + cls: "clawvault-status-list-item clawvault-open-loop-warning", + }); + const ageDays = this.getAgeInDays(task.createdAt ?? new Date(task.file.stat.ctime)); + const link = row.createEl("a", { + text: task.frontmatter.title ?? task.file.basename, + cls: "clawvault-blocked-link", + }); + link.addEventListener("click", (event) => { + event.preventDefault(); + void this.app.workspace.openLinkText(task.file.path, "", "tab"); + }); + row.createDiv({ + text: `${ageDays}d open`, + cls: "clawvault-status-list-meta", + }); + } + } + + const quickActionsSection = this.statusContentEl.createDiv({ + cls: "clawvault-status-section clawvault-status-quick-actions", + }); + quickActionsSection.createEl("h4", { text: "Quick actions" }); + const actionsRow = quickActionsSection.createDiv({ cls: "clawvault-quick-action-row" }); + this.renderQuickActionButton( + actionsRow, + "Add Task", + COMMAND_IDS.ADD_TASK + ); + this.renderQuickActionButton( + actionsRow, + "Quick Capture", + COMMAND_IDS.QUICK_CAPTURE + ); + this.renderQuickActionButton( + actionsRow, + "Generate Dashboard", + COMMAND_IDS.GENERATE_DASHBOARD + ); + // Refresh button const footer = this.statusContentEl.createDiv({ cls: "clawvault-status-footer" }); const refreshBtn = footer.createEl("button", { @@ -199,4 +338,36 @@ export class ClawVaultStatusView extends ItemView { if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } + + private getAgeInDays(date: Date): number { + const diffMs = Date.now() - date.getTime(); + return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24))); + } + + private renderQuickActionButton( + parent: HTMLElement, + label: string, + commandId: string + ): void { + const button = parent.createEl("button", { + text: label, + cls: "clawvault-quick-action-btn", + }); + button.addEventListener("click", () => { + void this.executeCommandById(commandId); + }); + } + + private async executeCommandById(commandId: string): Promise { + const commandManager = ( + this.app as typeof this.app & { + commands?: { + executeCommandById: (id: string) => Promise | boolean; + }; + } + ).commands; + if (commandManager?.executeCommandById) { + await commandManager.executeCommandById(commandId); + } + } } diff --git a/src/task-board-view.ts b/src/task-board-view.ts new file mode 100644 index 0000000..2e98d60 --- /dev/null +++ b/src/task-board-view.ts @@ -0,0 +1,509 @@ +/** + * ClawVault Task Board View + * Kanban-style task board with drag-and-drop status updates + */ + +import { ItemView, Notice, TAbstractFile, TFile, WorkspaceLeaf } from "obsidian"; +import type ClawVaultPlugin from "./main"; +import { + DEFAULT_FOLDERS, + STATUS_ICONS, + TASK_BOARD_VIEW_TYPE, + TASK_PRIORITY, + TASK_STATUS, + TaskStatus, +} from "./constants"; +import type { ParsedTask, TaskFrontmatter } from "./vault-reader"; + +interface FilterState { + project: string; + priority: string; + owner: string; +} + +interface TaskBoardCardData { + file: TFile; + title: string; + status: TaskStatus; + priority: string; + project: string; + owner: string; + blockedBy: string; + createdAt: Date | null; +} + +const ALL_FILTER_VALUE = "__all__"; + +export class ClawVaultTaskBoardView extends ItemView { + plugin: ClawVaultPlugin; + private boardContentEl: HTMLElement | null = null; + private allTasks: TaskBoardCardData[] = []; + private filters: FilterState = { + project: ALL_FILTER_VALUE, + priority: ALL_FILTER_VALUE, + owner: ALL_FILTER_VALUE, + }; + private refreshTimerId: number | null = null; + private isRefreshing = false; + + constructor(leaf: WorkspaceLeaf, plugin: ClawVaultPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return TASK_BOARD_VIEW_TYPE; + } + + getDisplayText(): string { + return "ClawVault task board"; + } + + getIcon(): string { + return "kanban-square"; + } + + async onOpen(): Promise { + const container = this.containerEl.children[1]; + if (!container || !(container instanceof HTMLElement)) { + return; + } + + container.empty(); + container.addClass("clawvault-task-board-view"); + this.boardContentEl = container.createDiv({ cls: "clawvault-task-board-content" }); + + this.registerVaultListeners(); + await this.refresh(); + } + + async onClose(): Promise { + this.boardContentEl = null; + if (this.refreshTimerId !== null) { + window.clearTimeout(this.refreshTimerId); + this.refreshTimerId = null; + } + } + + async refresh(): Promise { + if (!this.boardContentEl || this.isRefreshing) { + return; + } + + this.isRefreshing = true; + this.boardContentEl.empty(); + this.boardContentEl.createDiv({ + text: "Loading tasks...", + cls: "clawvault-loading", + }); + + try { + const taskData = await this.plugin.vaultReader.getAllTasks(); + this.allTasks = taskData.map((task) => this.toCardData(task)); + this.renderBoard(); + } catch (error) { + this.renderError(error); + } finally { + this.isRefreshing = false; + } + } + + private registerVaultListeners(): void { + this.registerEvent( + this.app.vault.on("modify", (file) => { + if (this.isTaskFile(file)) { + this.scheduleRefresh(); + } + }) + ); + + this.registerEvent( + this.app.vault.on("create", (file) => { + if (this.isTaskFile(file)) { + this.scheduleRefresh(); + } + }) + ); + + this.registerEvent( + this.app.vault.on("delete", (file) => { + if (this.isTaskFile(file)) { + this.scheduleRefresh(); + } + }) + ); + + this.registerEvent( + this.app.vault.on("rename", (file) => { + if (this.isTaskFile(file)) { + this.scheduleRefresh(); + } + }) + ); + } + + private scheduleRefresh(): void { + if (this.refreshTimerId !== null) { + window.clearTimeout(this.refreshTimerId); + } + this.refreshTimerId = window.setTimeout(() => { + this.refreshTimerId = null; + void this.refresh(); + }, 150); + } + + private isTaskFile(file: TAbstractFile): file is TFile { + return ( + file instanceof TFile && + file.extension === "md" && + (file.path.startsWith(`${DEFAULT_FOLDERS.TASKS}/`) || + file.parent?.path === DEFAULT_FOLDERS.TASKS) + ); + } + + private toCardData(task: ParsedTask): TaskBoardCardData { + const frontmatter = task.frontmatter; + return { + file: task.file, + title: frontmatter.title?.trim() || task.file.basename, + status: task.status, + priority: this.normalizePriority(frontmatter.priority), + project: frontmatter.project?.trim() || "unassigned", + owner: frontmatter.owner?.trim() || "unassigned", + blockedBy: this.normalizeBlockedBy(frontmatter.blocked_by), + createdAt: task.createdAt, + }; + } + + private normalizePriority(priority: TaskFrontmatter["priority"]): string { + if ( + priority === TASK_PRIORITY.CRITICAL || + priority === TASK_PRIORITY.HIGH || + priority === TASK_PRIORITY.MEDIUM || + priority === TASK_PRIORITY.LOW + ) { + return priority; + } + return TASK_PRIORITY.MEDIUM; + } + + private normalizeBlockedBy(blockedBy: TaskFrontmatter["blocked_by"]): string { + if (Array.isArray(blockedBy)) { + return blockedBy.join(", "); + } + if (typeof blockedBy === "string") { + return blockedBy.trim(); + } + return ""; + } + + private renderBoard(): void { + if (!this.boardContentEl) { + return; + } + + this.boardContentEl.empty(); + this.renderFilterBar(); + + const boardEl = this.boardContentEl.createDiv({ cls: "clawvault-kanban-board" }); + const statuses: TaskStatus[] = [ + TASK_STATUS.OPEN, + TASK_STATUS.IN_PROGRESS, + TASK_STATUS.BLOCKED, + TASK_STATUS.DONE, + ]; + + for (const status of statuses) { + const tasks = this.getFilteredTasks().filter((task) => task.status === status); + const columnEl = boardEl.createDiv({ + cls: "clawvault-kanban-column", + }); + columnEl.dataset.status = status; + + const columnHeader = columnEl.createDiv({ cls: "clawvault-kanban-column-header" }); + columnHeader.createSpan({ + text: `${STATUS_ICONS[status]} ${this.getStatusLabel(status)}`, + cls: "clawvault-kanban-column-title", + }); + columnHeader.createSpan({ + text: `${tasks.length}`, + cls: "clawvault-kanban-column-count", + }); + + const columnBody = columnEl.createDiv({ cls: "clawvault-kanban-column-body" }); + columnBody.addEventListener("dragover", (event) => { + event.preventDefault(); + columnBody.addClass("is-drag-over"); + }); + columnBody.addEventListener("dragleave", () => { + columnBody.removeClass("is-drag-over"); + }); + columnBody.addEventListener("drop", (event) => { + event.preventDefault(); + columnBody.removeClass("is-drag-over"); + const taskPath = event.dataTransfer?.getData("text/plain"); + if (taskPath) { + void this.handleDrop(taskPath, status); + } + }); + + if (tasks.length === 0) { + columnBody.createDiv({ + text: "No tasks", + cls: "clawvault-kanban-empty", + }); + } + + for (const task of tasks) { + this.renderTaskCard(columnBody, task); + } + } + } + + private renderFilterBar(): void { + if (!this.boardContentEl) { + return; + } + + const filterBar = this.boardContentEl.createDiv({ cls: "clawvault-board-filter-bar" }); + filterBar.createSpan({ text: "Filter:", cls: "clawvault-board-filter-label" }); + + this.renderFilterSelect( + filterBar, + "Project", + this.filters.project, + this.getFilterValues((task) => task.project), + (value) => { + this.filters.project = value; + this.renderBoard(); + } + ); + + this.renderFilterSelect( + filterBar, + "Priority", + this.filters.priority, + this.getFilterValues((task) => task.priority), + (value) => { + this.filters.priority = value; + this.renderBoard(); + } + ); + + this.renderFilterSelect( + filterBar, + "Owner", + this.filters.owner, + this.getFilterValues((task) => task.owner), + (value) => { + this.filters.owner = value; + this.renderBoard(); + } + ); + + const clearBtn = filterBar.createEl("button", { + text: "Reset", + cls: "clawvault-board-filter-reset", + }); + clearBtn.addEventListener("click", () => { + this.filters = { + project: ALL_FILTER_VALUE, + priority: ALL_FILTER_VALUE, + owner: ALL_FILTER_VALUE, + }; + this.renderBoard(); + }); + } + + private renderFilterSelect( + parent: HTMLElement, + label: string, + currentValue: string, + options: string[], + onChange: (value: string) => void + ): void { + const wrapper = parent.createDiv({ cls: "clawvault-board-filter-select-wrap" }); + wrapper.createSpan({ + text: `${label}:`, + cls: "clawvault-board-filter-select-label", + }); + + const selectEl = wrapper.createEl("select", { + cls: "clawvault-board-filter-select", + }); + + selectEl.createEl("option", { + text: "All", + value: ALL_FILTER_VALUE, + }); + + for (const option of options) { + selectEl.createEl("option", { + text: option, + value: option, + }); + } + + selectEl.value = currentValue; + selectEl.addEventListener("change", () => { + onChange(selectEl.value); + }); + } + + private getFilterValues(selector: (task: TaskBoardCardData) => string): string[] { + const values = new Set(); + for (const task of this.allTasks) { + const value = selector(task).trim(); + if (value.length > 0) { + values.add(value); + } + } + return Array.from(values).sort((a, b) => a.localeCompare(b)); + } + + private getFilteredTasks(): TaskBoardCardData[] { + return this.allTasks.filter((task) => { + if ( + this.filters.project !== ALL_FILTER_VALUE && + task.project !== this.filters.project + ) { + return false; + } + if ( + this.filters.priority !== ALL_FILTER_VALUE && + task.priority !== this.filters.priority + ) { + return false; + } + if (this.filters.owner !== ALL_FILTER_VALUE && task.owner !== this.filters.owner) { + return false; + } + return true; + }); + } + + private renderTaskCard(parent: HTMLElement, task: TaskBoardCardData): void { + const cardEl = parent.createDiv({ + cls: `clawvault-task-card clawvault-task-priority-${task.priority}`, + }); + cardEl.draggable = true; + cardEl.dataset.path = task.file.path; + + cardEl.addEventListener("dragstart", (event) => { + event.dataTransfer?.setData("text/plain", task.file.path); + event.dataTransfer?.setData("text/clawvault-task-status", task.status); + cardEl.addClass("is-dragging"); + }); + + cardEl.addEventListener("dragend", () => { + cardEl.removeClass("is-dragging"); + }); + + cardEl.addEventListener("click", () => { + void this.app.workspace.openLinkText(task.file.path, "", "tab"); + }); + + cardEl.createDiv({ + text: task.title, + cls: "clawvault-task-card-title", + }); + + const metaLine = cardEl.createDiv({ cls: "clawvault-task-card-meta" }); + metaLine.createSpan({ text: task.project }); + metaLine.createSpan({ text: " · " }); + metaLine.createSpan({ + text: task.priority, + cls: `clawvault-priority-${task.priority}`, + }); + if (task.owner !== "unassigned") { + metaLine.createSpan({ text: " · " }); + metaLine.createSpan({ text: task.owner }); + } + + if (task.status === TASK_STATUS.BLOCKED && task.blockedBy) { + cardEl.createDiv({ + text: `Blocked by: ${task.blockedBy}`, + cls: "clawvault-task-card-blocked-by", + }); + } + } + + private async handleDrop(taskPath: string, newStatus: TaskStatus): Promise { + const abstractFile = this.app.vault.getAbstractFileByPath(taskPath); + if (!(abstractFile instanceof TFile)) { + return; + } + + const task = this.allTasks.find((item) => item.file.path === taskPath); + if (!task || task.status === newStatus) { + return; + } + + try { + await this.app.vault.process(abstractFile, (content) => + this.updateStatusInFrontmatter(content, newStatus) + ); + task.status = newStatus; + this.renderBoard(); + new Notice(`Task moved to ${this.getStatusLabel(newStatus)}`); + } catch (error) { + console.error("ClawVault: Failed to update task status from board", error); + new Notice("Could not update task status"); + } + } + + private updateStatusInFrontmatter(content: string, status: TaskStatus): string { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + return `---\nstatus: ${status}\n---\n\n${content}`; + } + + const fullFrontmatter = frontmatterMatch[0]; + const body = frontmatterMatch[1] ?? ""; + const bodyLines = body.split("\n"); + let updated = false; + + const newBodyLines = bodyLines.map((line) => { + if (/^status\s*:/i.test(line)) { + updated = true; + return `status: ${status}`; + } + return line; + }); + + if (!updated) { + newBodyLines.push(`status: ${status}`); + } + + const newFrontmatter = `---\n${newBodyLines.join("\n")}\n---`; + return content.replace(fullFrontmatter, newFrontmatter); + } + + private getStatusLabel(status: TaskStatus): string { + switch (status) { + case TASK_STATUS.OPEN: + return "Open"; + case TASK_STATUS.IN_PROGRESS: + return "In progress"; + case TASK_STATUS.BLOCKED: + return "Blocked"; + case TASK_STATUS.DONE: + return "Done"; + default: + return status; + } + } + + private renderError(error: unknown): void { + if (!this.boardContentEl) { + return; + } + this.boardContentEl.empty(); + const errorEl = this.boardContentEl.createDiv({ cls: "clawvault-status-error" }); + errorEl.createEl("h4", { text: "Task board unavailable" }); + errorEl.createEl("p", { + text: error instanceof Error ? error.message : "Unknown error", + cls: "clawvault-error-details", + }); + } +} diff --git a/src/vault-reader.ts b/src/vault-reader.ts index 63f9c29..ad550db 100644 --- a/src/vault-reader.ts +++ b/src/vault-reader.ts @@ -8,6 +8,7 @@ import { CLAWVAULT_CONFIG_FILE, CLAWVAULT_GRAPH_INDEX, DEFAULT_FOLDERS, + TaskPriority, TaskStatus, TASK_STATUS, } from "./constants"; @@ -43,12 +44,28 @@ export interface ClawVaultConfig { // Task frontmatter structure export interface TaskFrontmatter { status?: TaskStatus; - priority?: string; + priority?: TaskPriority | string; project?: string; owner?: string; blocked_by?: string | string[]; due?: string; title?: string; + tags?: string[] | string; + created?: string; + completed?: string | null; + source?: string; +} + +export interface ParsedTask { + file: TFile; + frontmatter: TaskFrontmatter; + status: TaskStatus; + createdAt: Date | null; +} + +export interface ObservationSession { + file: TFile; + timestamp: Date; } // Vault statistics @@ -184,6 +201,46 @@ export class VaultReader { return null; } + /** + * Parse a frontmatter date field into a Date value + */ + private parseDate(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + if (typeof value === "number") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof value === "string" && value.trim().length > 0) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; + } + + /** + * Get all tasks from the tasks folder + */ + async getAllTasks(): Promise { + const taskFiles = this.getFilesInFolder(DEFAULT_FOLDERS.TASKS); + const tasks: ParsedTask[] = []; + + for (const file of taskFiles) { + const frontmatter = (await this.parseFrontmatter(file)) ?? {}; + const status = frontmatter.status ?? TASK_STATUS.OPEN; + const createdAt = this.parseDate(frontmatter.created) ?? new Date(file.stat.ctime); + tasks.push({ + file, + frontmatter, + status, + createdAt, + }); + } + + return tasks; + } + /** * Get task statistics from task files */ @@ -196,30 +253,24 @@ export class VaultReader { total: 0, }; - const taskFiles = this.getFilesInFolder(DEFAULT_FOLDERS.TASKS); - - for (const file of taskFiles) { - const frontmatter = await this.parseFrontmatter(file); + const tasks = await this.getAllTasks(); + + for (const task of tasks) { stats.total++; - - if (frontmatter?.status) { - switch (frontmatter.status) { - case TASK_STATUS.IN_PROGRESS: - stats.active++; - break; - case TASK_STATUS.OPEN: - stats.open++; - break; - case TASK_STATUS.BLOCKED: - stats.blocked++; - break; - case TASK_STATUS.DONE: - stats.completed++; - break; - } - } else { - // Default to open if no status - stats.open++; + + switch (task.status) { + case TASK_STATUS.IN_PROGRESS: + stats.active++; + break; + case TASK_STATUS.OPEN: + stats.open++; + break; + case TASK_STATUS.BLOCKED: + stats.blocked++; + break; + case TASK_STATUS.DONE: + stats.completed++; + break; } } @@ -231,18 +282,73 @@ export class VaultReader { */ async getBlockedTasks(): Promise> { const blockedTasks: Array<{ file: TFile; frontmatter: TaskFrontmatter }> = []; - const taskFiles = this.getFilesInFolder(DEFAULT_FOLDERS.TASKS); - for (const file of taskFiles) { - const frontmatter = await this.parseFrontmatter(file); - if (frontmatter?.status === TASK_STATUS.BLOCKED) { - blockedTasks.push({ file, frontmatter }); + for (const task of await this.getAllTasks()) { + if (task.status === TASK_STATUS.BLOCKED) { + blockedTasks.push({ file: task.file, frontmatter: task.frontmatter }); } } return blockedTasks; } + /** + * Get backlog tasks (open status), newest first + */ + async getBacklogTasks(limit = 5): Promise { + const openTasks = (await this.getAllTasks()) + .filter((task) => task.status === TASK_STATUS.OPEN) + .sort((a, b) => b.file.stat.mtime - a.file.stat.mtime); + + return openTasks.slice(0, limit); + } + + /** + * Get open loops: non-completed tasks older than a threshold + */ + async getOpenLoops(daysOpen = 7): Promise { + const ageThreshold = Date.now() - daysOpen * 24 * 60 * 60 * 1000; + const tasks = await this.getAllTasks(); + return tasks + .filter((task) => { + if (task.status === TASK_STATUS.DONE) { + return false; + } + const createdAt = task.createdAt ?? new Date(task.file.stat.ctime); + return createdAt.getTime() < ageThreshold; + }) + .sort((a, b) => { + const aTime = a.createdAt?.getTime() ?? a.file.stat.ctime; + const bTime = b.createdAt?.getTime() ?? b.file.stat.ctime; + return aTime - bTime; + }); + } + + /** + * Get recent observation sessions by file modified time + */ + async getRecentObservationSessions(limit = 5): Promise { + const observationFiles = this.getFilesInFolder("observations") + .sort((a, b) => b.stat.mtime - a.stat.mtime) + .slice(0, limit); + + return observationFiles.map((file) => ({ + file, + timestamp: new Date(file.stat.mtime), + })); + } + + /** + * Get recent decisions updated within a date window + */ + getRecentDecisionFiles(days = 7, limit = 10): TFile[] { + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + return this.getFilesInFolder("decisions") + .filter((file) => file.stat.mtime >= cutoff) + .sort((a, b) => b.stat.mtime - a.stat.mtime) + .slice(0, limit); + } + /** * Get complete vault statistics */ diff --git a/styles.css b/styles.css index e95d562..db0d7de 100644 --- a/styles.css +++ b/styles.css @@ -64,6 +64,28 @@ padding: 8px 0; } +.clawvault-status-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.clawvault-status-list-item { + padding: 8px 10px; + border-radius: 6px; + background-color: var(--background-secondary); +} + +.clawvault-status-list-meta { + font-size: 0.8em; + color: var(--text-muted); + margin-top: 4px; +} + +.clawvault-open-loop-warning { + border-left: 3px solid var(--clawvault-color-inbox); +} + .clawvault-status-section h4 { margin: 0 0 8px 0; font-size: 0.95em; @@ -150,6 +172,21 @@ border-top: 1px solid var(--background-modifier-border); } +.clawvault-status-quick-actions { + padding-top: 0; +} + +.clawvault-quick-action-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.clawvault-quick-action-btn { + flex: 1; + min-width: 120px; +} + .clawvault-refresh-btn { width: 100%; } @@ -176,17 +213,30 @@ .clawvault-capture-modal, .clawvault-task-modal, -.clawvault-blocked-modal { +.clawvault-blocked-modal, +.clawvault-template-modal, +.clawvault-open-loops-modal { max-width: 500px; } .clawvault-capture-modal h2, .clawvault-task-modal h2, -.clawvault-blocked-modal h2 { +.clawvault-blocked-modal h2, +.clawvault-template-modal h2, +.clawvault-open-loops-modal h2 { margin-top: 0; margin-bottom: 16px; } +.clawvault-template-description { + font-size: 0.9em; + color: var(--text-muted); + background-color: var(--background-secondary); + padding: 10px 12px; + border-radius: 6px; + margin-bottom: 12px; +} + /* Textareas */ .clawvault-capture-textarea, .clawvault-task-textarea { @@ -246,6 +296,15 @@ border-left: 3px solid var(--clawvault-color-blocked); } +.clawvault-open-loop-item { + border-left-color: var(--clawvault-color-inbox); +} + +.clawvault-open-loop-age { + color: var(--clawvault-color-inbox); + font-weight: 600; +} + .clawvault-blocked-title { font-weight: 600; margin-bottom: 6px; @@ -323,6 +382,152 @@ color: var(--text-muted); } +/* ========================================================================== + Task board view + ========================================================================== */ + +.clawvault-task-board-view { + padding: 16px; +} + +.clawvault-task-board-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.clawvault-board-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.clawvault-board-filter-label { + font-size: 0.9em; + color: var(--text-muted); +} + +.clawvault-board-filter-select-wrap { + display: flex; + align-items: center; + gap: 6px; +} + +.clawvault-board-filter-select-label { + font-size: 0.85em; + color: var(--text-muted); +} + +.clawvault-board-filter-select { + min-width: 120px; +} + +.clawvault-board-filter-reset { + margin-left: auto; +} + +.clawvault-kanban-board { + display: grid; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + gap: 12px; + align-items: start; +} + +.clawvault-kanban-column { + background-color: var(--background-secondary); + border-radius: 8px; + min-height: 280px; + border: 1px solid var(--background-modifier-border); + display: flex; + flex-direction: column; +} + +.clawvault-kanban-column-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.clawvault-kanban-column-title { + font-weight: 600; + font-size: 0.9em; +} + +.clawvault-kanban-column-count { + font-size: 0.8em; + color: var(--text-muted); +} + +.clawvault-kanban-column-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + min-height: 220px; + transition: background-color 0.15s ease; +} + +.clawvault-kanban-column-body.is-drag-over { + background-color: var(--background-modifier-hover); +} + +.clawvault-kanban-empty { + font-size: 0.85em; + color: var(--text-muted); + text-align: center; + padding: 8px; +} + +.clawvault-task-card { + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-left-width: 4px; + border-radius: 6px; + padding: 8px 10px; + cursor: pointer; + user-select: none; +} + +.clawvault-task-card.is-dragging { + opacity: 0.65; +} + +.clawvault-task-card-title { + font-size: 0.9em; + font-weight: 600; + margin-bottom: 6px; +} + +.clawvault-task-card-meta { + font-size: 0.8em; + color: var(--text-muted); +} + +.clawvault-task-card-blocked-by { + font-size: 0.8em; + color: var(--clawvault-color-blocked); + margin-top: 6px; +} + +.clawvault-task-priority-critical { + border-left-color: var(--clawvault-color-blocked); +} + +.clawvault-task-priority-high { + border-left-color: var(--clawvault-color-decisions); +} + +.clawvault-task-priority-medium { + border-left-color: var(--clawvault-color-people); +} + +.clawvault-task-priority-low { + border-left-color: var(--clawvault-color-backlog); +} + /* ========================================================================== File Explorer Decorations ========================================================================== */ @@ -416,6 +621,16 @@ stroke-opacity: 1; } +/* Enhanced graph styles */ +.graph-view .node.clawvault-node-tagged circle { + fill: var(--clawvault-tag-color) !important; +} + +.graph-view .node.clawvault-node-critical circle { + transform: scale(1.35); + transform-origin: center; +} + /* ========================================================================== Settings Tab ========================================================================== */ @@ -451,6 +666,14 @@ .clawvault-modal-buttons button { width: 100%; } + + .clawvault-kanban-board { + grid-template-columns: 1fr; + } + + .clawvault-board-filter-reset { + margin-left: 0; + } } /* ==========================================================================