diff --git a/src/main.ts b/src/main.ts index 568cc5c..6bc7526 100644 --- a/src/main.ts +++ b/src/main.ts @@ -184,8 +184,11 @@ export default class ClawVaultPlugin extends Plugin { try { const stats = await this.vaultReader.getVaultStats(); const activeTaskCount = stats.tasks.active + stats.tasks.open; + const overdueSuffix = stats.tasks.overdue > 0 + ? ` ยท โš  ${stats.tasks.overdue} overdue` + : ""; this.statusBarItem.setText( - `๐Ÿ˜ ${stats.nodeCount.toLocaleString()} nodes ยท ${activeTaskCount} tasks` + `๐Ÿ˜ ${stats.nodeCount.toLocaleString()} nodes ยท ${activeTaskCount} tasks${overdueSuffix}` ); } catch { this.statusBarItem.setText("๐Ÿ˜ ClawVault"); diff --git a/src/status-view.ts b/src/status-view.ts index 60a4f11..b0dda22 100644 --- a/src/status-view.ts +++ b/src/status-view.ts @@ -11,6 +11,7 @@ import type { ObservationSession, ParsedTask, VaultStats } from "./vault-reader" interface StatusViewData { stats: VaultStats; backlogItems: ParsedTask[]; + overdueItems: ParsedTask[]; recentSessions: ObservationSession[]; openLoops: ParsedTask[]; graphTypes: Record; @@ -65,9 +66,10 @@ export class ClawVaultStatusView extends ItemView { this.statusContentEl.empty(); try { - const [stats, backlogItems, recentSessions, openLoops, graphTypes, todayObs] = await Promise.all([ + const [stats, backlogItems, overdueItems, recentSessions, openLoops, graphTypes, todayObs] = await Promise.all([ this.plugin.vaultReader.getVaultStats(), this.plugin.vaultReader.getBacklogTasks(5), + this.plugin.vaultReader.getOverdueTasks(), this.plugin.vaultReader.getRecentObservationSessions(5), this.plugin.vaultReader.getOpenLoops(7), this.plugin.vaultReader.getGraphTypeSummary(), @@ -76,6 +78,7 @@ export class ClawVaultStatusView extends ItemView { this.renderStats({ stats, backlogItems, + overdueItems, recentSessions, openLoops, graphTypes, @@ -91,7 +94,7 @@ export class ClawVaultStatusView extends ItemView { */ private renderStats(data: StatusViewData): void { if (!this.statusContentEl) return; - const { stats, backlogItems, recentSessions, openLoops, graphTypes, todayObs } = data; + const { stats, backlogItems, overdueItems, recentSessions, openLoops, graphTypes, todayObs } = data; // Header const header = this.statusContentEl.createDiv({ cls: "clawvault-status-header" }); @@ -177,6 +180,10 @@ export class ClawVaultStatusView extends ItemView { statusLine.createSpan({ text: `โ—‹ ${stats.tasks.open} open`, cls: "clawvault-task-open" }); statusLine.createSpan({ text: " | " }); statusLine.createSpan({ text: `โŠ˜ ${stats.tasks.blocked} blocked`, cls: "clawvault-task-blocked" }); + taskStats.createDiv({ + text: `๐Ÿ“… ${stats.tasks.withDue} with due dates`, + cls: "clawvault-due-date", + }); // Completed with percentage const completedPct = stats.tasks.total > 0 @@ -193,6 +200,34 @@ export class ClawVaultStatusView extends ItemView { progressFill.style.width = `${completedPct}%`; } + // Overdue section + if (overdueItems.length > 0) { + const overdueSection = this.statusContentEl.createDiv({ + cls: "clawvault-status-section clawvault-overdue-warning", + }); + overdueSection.createEl("h4", { + text: `โš  Overdue (${overdueItems.length})`, + cls: "clawvault-overdue-warning", + }); + const overdueList = overdueSection.createDiv({ cls: "clawvault-status-list" }); + for (const task of overdueItems) { + const row = overdueList.createDiv({ + cls: "clawvault-status-list-item clawvault-overdue-warning", + }); + 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"); + }); + this.renderTaskDueDate(row, task.frontmatter.due, true); + this.renderTaskDependencies(row, task); + this.renderTaskTags(row, task.frontmatter.tags); + } + } + // Backlog section const backlogSection = this.statusContentEl.createDiv({ cls: "clawvault-status-section" }); backlogSection.createEl("h4", { @@ -228,6 +263,9 @@ export class ClawVaultStatusView extends ItemView { cls: `clawvault-priority-${task.frontmatter.priority}`, }); } + this.renderTaskDueDate(item, task.frontmatter.due); + this.renderTaskDependencies(item, task); + this.renderTaskTags(item, task.frontmatter.tags); } } @@ -399,6 +437,128 @@ export class ClawVaultStatusView extends ItemView { return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24))); } + private parseDateValue(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 normalized = value.trim(); + const ymdMatch = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/); + const date = ymdMatch + ? new Date( + Number.parseInt(ymdMatch[1] ?? "0", 10), + Number.parseInt(ymdMatch[2] ?? "1", 10) - 1, + Number.parseInt(ymdMatch[3] ?? "1", 10) + ) + : new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; + } + + private startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + + private getDayDelta(date: Date): number { + const dayMs = 1000 * 60 * 60 * 24; + const target = this.startOfDay(date).getTime(); + const today = this.startOfDay(new Date()).getTime(); + return Math.floor((target - today) / dayMs); + } + + private renderTaskDueDate(parent: HTMLElement, dueValue: unknown, overdueOnly = false): void { + const dueDate = this.parseDateValue(dueValue); + if (!dueDate) return; + + const delta = this.getDayDelta(dueDate); + const dueMeta = parent.createDiv({ + cls: "clawvault-status-list-meta clawvault-due-date", + }); + const formattedDate = dueDate.toLocaleDateString(); + + if (delta < 0 || overdueOnly) { + const overdueDays = Math.max(1, Math.abs(delta)); + dueMeta.setText(`Due ${formattedDate} ยท ${overdueDays}d overdue`); + dueMeta.addClass("clawvault-overdue-warning"); + return; + } + + if (delta === 0) { + dueMeta.setText(`Due ${formattedDate} ยท due today`); + return; + } + + dueMeta.setText(`Due ${formattedDate} ยท ${delta}d left`); + } + + private renderTaskDependencies(parent: HTMLElement, task: ParsedTask): void { + const dependencies = new Set(); + if (Array.isArray(task.frontmatter.depends_on)) { + for (const dep of task.frontmatter.depends_on) { + const normalized = dep.trim(); + if (normalized.length > 0) dependencies.add(normalized); + } + } + + const blockedBy = task.frontmatter.blocked_by; + if (Array.isArray(blockedBy)) { + for (const dep of blockedBy) { + const normalized = dep.trim(); + if (normalized.length > 0) dependencies.add(normalized); + } + } else if (typeof blockedBy === "string" && blockedBy.trim().length > 0) { + dependencies.add(blockedBy.trim()); + } + + if (dependencies.size === 0) return; + + parent.createDiv({ + text: `Depends on: ${Array.from(dependencies).join(", ")}`, + cls: "clawvault-status-list-meta", + }); + } + + private normalizeTags(tags: string[] | string | undefined): string[] { + if (Array.isArray(tags)) { + return tags + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + } + + if (typeof tags !== "string") { + return []; + } + + const normalizedInput = tags.trim(); + if (normalizedInput.length === 0) { + return []; + } + + const rawParts = normalizedInput.includes(",") + ? normalizedInput.split(",") + : normalizedInput.split(/\s+/); + return rawParts + .map((part) => part.trim()) + .filter((part) => part.length > 0); + } + + private renderTaskTags(parent: HTMLElement, tags: string[] | string | undefined): void { + const normalizedTags = this.normalizeTags(tags); + if (normalizedTags.length === 0) return; + + const tagsEl = parent.createDiv({ cls: "clawvault-task-tags" }); + for (const tag of normalizedTags) { + tagsEl.createSpan({ + text: tag.startsWith("#") ? tag : `#${tag}`, + }); + } + } + private renderQuickActionButton( parent: HTMLElement, label: string, diff --git a/src/vault-reader.ts b/src/vault-reader.ts index bc5f39f..7de30b1 100644 --- a/src/vault-reader.ts +++ b/src/vault-reader.ts @@ -64,6 +64,13 @@ export interface TaskFrontmatter { created?: string; completed?: string | null; source?: string; + description?: string; + estimate?: string; + parent?: string; + depends_on?: string[]; + escalation?: boolean; + confidence?: number; + reason?: string; } export interface ParsedTask { @@ -90,6 +97,8 @@ export interface VaultStats { blocked: number; completed: number; total: number; + withDue: number; + overdue: number; }; inboxCount: number; lastObservation?: Date; @@ -291,12 +300,27 @@ export class VaultReader { return Number.isNaN(date.getTime()) ? null : date; } if (typeof value === "string" && value.trim().length > 0) { - const date = new Date(value); + const normalized = value.trim(); + const ymdMatch = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/); + const date = ymdMatch + ? new Date( + Number.parseInt(ymdMatch[1] ?? "0", 10), + Number.parseInt(ymdMatch[2] ?? "1", 10) - 1, + Number.parseInt(ymdMatch[3] ?? "1", 10) + ) + : new Date(normalized); return Number.isNaN(date.getTime()) ? null : date; } return null; } + /** + * Normalize a date to local-day granularity. + */ + private startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + /** * Get all tasks from the tasks folder */ @@ -329,12 +353,23 @@ export class VaultReader { blocked: 0, completed: 0, total: 0, + withDue: 0, + overdue: 0, }; const tasks = await this.getAllTasks(); + const todayStart = this.startOfDay(new Date()).getTime(); for (const task of tasks) { stats.total++; + const dueDate = this.parseDate(task.frontmatter.due); + const isDone = task.status === TASK_STATUS.DONE; + if (dueDate && !isDone) { + stats.withDue++; + if (this.startOfDay(dueDate).getTime() < todayStart) { + stats.overdue++; + } + } switch (task.status) { case TASK_STATUS.IN_PROGRESS: @@ -355,6 +390,34 @@ export class VaultReader { return stats; } + /** + * Get overdue tasks sorted by due date (oldest due date first). + */ + async getOverdueTasks(): Promise { + const todayStart = this.startOfDay(new Date()).getTime(); + const tasks = await this.getAllTasks(); + + return tasks + .filter((task) => { + if (task.status === TASK_STATUS.DONE) { + return false; + } + const dueDate = this.parseDate(task.frontmatter.due); + if (!dueDate) { + return false; + } + return this.startOfDay(dueDate).getTime() < todayStart; + }) + .sort((a, b) => { + const aDue = this.parseDate(a.frontmatter.due); + const bDue = this.parseDate(b.frontmatter.due); + if (!aDue && !bDue) return 0; + if (!aDue) return 1; + if (!bDue) return -1; + return this.startOfDay(aDue).getTime() - this.startOfDay(bDue).getTime(); + }); + } + /** * Get all blocked tasks with their details */ diff --git a/styles.css b/styles.css index 048d6fe..10e3f95 100644 --- a/styles.css +++ b/styles.css @@ -86,6 +86,36 @@ border-left: 3px solid var(--clawvault-color-inbox); } +.clawvault-overdue-warning { + color: var(--text-error); +} + +.clawvault-status-list-item.clawvault-overdue-warning { + border-left: 3px solid var(--text-error); +} + +.clawvault-due-date { + font-size: 0.8em; + color: var(--text-muted); +} + +.clawvault-task-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.clawvault-task-tags > span { + font-size: 0.75em; + line-height: 1.4; + padding: 2px 8px; + border-radius: 999px; + background-color: var(--background-modifier-hover); + color: var(--text-muted); + border: 1px solid var(--background-modifier-border); +} + .clawvault-status-section h4 { margin: 0 0 8px 0; font-size: 0.95em;