Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
164 changes: 162 additions & 2 deletions src/status-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
Expand Down Expand Up @@ -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(),
Expand All @@ -76,6 +78,7 @@ export class ClawVaultStatusView extends ItemView {
this.renderStats({
stats,
backlogItems,
overdueItems,
recentSessions,
openLoops,
graphTypes,
Expand All @@ -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" });
Expand Down Expand Up @@ -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
Expand All @@ -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", {
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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<string>();
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,
Expand Down
65 changes: 64 additions & 1 deletion src/vault-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -90,6 +97,8 @@ export interface VaultStats {
blocked: number;
completed: number;
total: number;
withDue: number;
overdue: number;
};
inboxCount: number;
lastObservation?: Date;
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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:
Expand All @@ -355,6 +390,34 @@ export class VaultReader {
return stats;
}

/**
* Get overdue tasks sorted by due date (oldest due date first).
*/
async getOverdueTasks(): Promise<ParsedTask[]> {
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
*/
Expand Down
30 changes: 30 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading