feat(widgets): add embeddable widget system with codeblock support

Introduce a new widget architecture for embedding task views directly in
notes via codeblocks. Includes:

- Core widget framework: BaseWidgetView, WidgetShell, WidgetFactory
- Widget views: Tasks, Calendar, Kanban, Projects, Forecast
- Codeblock processor for `task-genius-widget` syntax
- Responsive layouts (compact/narrow/medium/wide)
- Calendar component: configurable view mode persistence per instance
- Comprehensive widget styling with mini calendar variant
This commit is contained in:
Quorafind 2025-12-15 22:46:16 +08:00
parent 862e22f8d4
commit bb3cc33c08
22 changed files with 5228 additions and 12 deletions

1
.gitignore vendored
View file

@ -53,3 +53,4 @@ packages/*/node_modules
# Documentation site (separate repository)
docs-site
PLAN.md

View file

@ -120,6 +120,9 @@ export class CalendarComponent extends Component {
onTaskSelected?: (task: Task | null) => void;
onTaskCompleted?: (task: Task) => void;
onEventContextMenu?: (ev: MouseEvent, event: CalendarEvent) => void;
onViewModeChange?: (viewMode: CalendarViewMode) => void;
persistViewMode?: boolean;
viewModeStorageKey?: string;
} = {},
private viewId: string = "calendar",
) {
@ -134,18 +137,47 @@ export class CalendarComponent extends Component {
"calendar-view-container",
);
// Load saved view mode
const savedView = this.app.loadLocalStorage(
"task-genius:calendar-view",
);
if (savedView) {
this.currentViewMode = savedView as CalendarViewMode;
}
this.loadSavedViewMode();
// Initialize view registry with custom views (agenda, year)
this.initializeViewRegistry();
}
private shouldPersistViewMode(): boolean {
return this.params.persistViewMode ?? true;
}
private getViewModeStorageKey(): string {
return (
this.params.viewModeStorageKey ??
`task-genius:calendar-view:${this.viewId}`
);
}
private loadSavedViewMode(): void {
if (!this.shouldPersistViewMode()) {
return;
}
const storageKey = this.getViewModeStorageKey();
const savedView =
this.app.loadLocalStorage(storageKey) ??
(this.viewId === "calendar"
? this.app.loadLocalStorage("task-genius:calendar-view")
: null);
if (savedView) {
this.currentViewMode = savedView as CalendarViewMode;
if (
this.viewId === "calendar" &&
storageKey !== "task-genius:calendar-view"
) {
this.app.saveLocalStorage(storageKey, savedView);
}
}
}
/**
* Initialize the view registry with custom views (Agenda, Year)
* These views extend @taskgenius/calendar's BaseView for integration
@ -235,10 +267,23 @@ export class CalendarComponent extends Component {
* Save the current view mode to localStorage
*/
private saveCurrentViewMode() {
this.app.saveLocalStorage(
"task-genius:calendar-view",
this.currentViewMode,
);
this.params.onViewModeChange?.(this.currentViewMode);
if (!this.shouldPersistViewMode()) {
return;
}
const storageKey = this.getViewModeStorageKey();
this.app.saveLocalStorage(storageKey, this.currentViewMode);
if (
this.viewId === "calendar" &&
storageKey !== "task-genius:calendar-view"
) {
this.app.saveLocalStorage(
"task-genius:calendar-view",
this.currentViewMode,
);
}
}
public navigate(direction: "prev" | "next") {

View file

@ -83,6 +83,7 @@ import "./styles/onboarding.scss";
import "./styles/universal-suggest.scss";
import "./styles/noise.scss";
import "./styles/changelog.scss";
import "./styles/widgets.scss";
import {
TASK_SPECIFIC_VIEW_TYPE,
@ -114,6 +115,11 @@ import DesktopIntegrationManager from "./managers/desktop-integration-manager";
import { OnboardingConfigManager } from "./managers/onboarding-manager";
import { OnCompletionManager } from "./managers/completion-manager";
import { SettingsChangeDetector } from "./services/settings-change-detector";
import {
registerWidgetCommands,
registerWidgetViews,
} from "./widgets/registerWidgets";
import { registerWidgetCodeBlock } from "./widgets/codeblock/WidgetCodeBlockProcessor";
import {
ONBOARDING_VIEW_TYPE,
OnboardingView,
@ -534,6 +540,12 @@ export default class TaskProgressBarPlugin extends Plugin {
} catch (error) {
console.log("Failed to register Bases views:", error);
}
try {
registerWidgetViews(this);
} catch (error) {
console.log("Failed to register Widget views:", error);
}
}
private registerOnboardingView(): void {
@ -610,6 +622,21 @@ export default class TaskProgressBarPlugin extends Plugin {
this.openSettingsModal();
},
});
try {
registerWidgetCommands(this);
} catch (error) {
console.log("Failed to register Widget commands:", error);
}
try {
registerWidgetCodeBlock(this);
} catch (error) {
console.log(
"Failed to register Widget codeblock processor:",
error,
);
}
}
private deferIconRegistration(): void {

906
src/styles/widgets.scss Normal file
View file

@ -0,0 +1,906 @@
.tg-widget-view {
padding: 0;
}
// ============================================
// Embedded Widget (CodeBlock) Styles
// ============================================
.tg-embedded-widget {
margin: 8px 0;
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
overflow: hidden;
background: var(--background-primary);
}
.tg-widget-renderer {
display: flex;
flex-direction: column;
overflow: hidden;
}
.tg-widget-renderer-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
}
.tg-widget-renderer-title {
font-size: 12px;
font-weight: 600;
color: var(--text-normal);
}
.tg-widget-renderer-body {
padding: 10px;
overflow: auto;
flex: 1;
min-height: 0;
}
// Layout mode classes for renderer
.tg-renderer-compact,
.tg-renderer-narrow,
.tg-renderer-medium,
.tg-renderer-wide {
// Base styles - can be extended per widget
}
// Placeholder for upcoming widgets
.tg-embedded-widget-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 100px;
background: var(--background-secondary);
}
.tg-embedded-widget-placeholder-text {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
}
// Error state
.tg-embedded-widget-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--background-modifier-error);
border: 1px solid var(--background-modifier-error-hover);
border-radius: 6px;
margin: 8px 0;
}
.tg-embedded-widget-error-icon {
font-size: 16px;
}
.tg-embedded-widget-error-message {
font-size: 12px;
color: var(--text-error);
}
// Embedded Tasks Widget adjustments
.tg-embedded-widget {
.tg-tasks-container {
max-height: none;
}
.tg-tasks-group-header {
padding: 4px 6px;
}
.tg-tasks-item {
padding: 4px 6px;
}
}
// Embedded Kanban Widget adjustments
.tg-embedded-widget {
.tg-kanban-container {
min-height: 150px;
}
.tg-kanban-board {
min-height: 150px;
}
.tg-kanban-column {
min-width: 140px;
max-width: 220px;
}
// In embedded mode, default to read-only cards (no drag cursor)
.tg-kanban-card {
cursor: default;
&:active {
cursor: default;
}
}
}
.tg-widget-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.tg-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--background-modifier-border);
}
.tg-widget-title {
font-size: 12px;
font-weight: 600;
color: var(--text-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tg-widget-actions {
display: flex;
align-items: center;
gap: 6px;
}
.tg-widget-link-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
color: var(--text-muted);
}
.tg-widget-link-toggle:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.tg-widget-body {
padding: 10px;
overflow: auto;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.tg-widget-section {
margin-bottom: 10px;
}
.tg-widget-section-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
}
.tg-widget-section-empty {
font-size: 12px;
color: var(--text-faint);
padding: 4px 0;
}
.tg-widget-task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.tg-widget-task-row {
display: grid;
grid-template-columns: 16px 1fr;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
}
.tg-widget-task-row:hover {
background: var(--background-modifier-hover);
}
.tg-widget-task-title {
font-size: 12px;
color: var(--text-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.tg-widget-empty {
font-size: 12px;
color: var(--text-faint);
padding: 6px 0;
}
// ============================================
// Calendar Widget
// ============================================
.tg-widget-calendar {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 8px;
}
.tg-widget-calendar-full,
.tg-widget-calendar-mini,
.tg-widget-calendar-mini-tasks {
min-height: 0;
}
// Full calendar (for wider layouts)
.tg-widget-calendar-full {
flex: 1;
overflow: auto;
.full-calendar-container {
height: 100%;
display: flex;
flex-direction: column;
}
.calendar-header {
flex-shrink: 0;
}
.calendar-view-container {
flex: 1;
min-height: 0;
overflow: auto;
}
}
// Mini calendar container (for compact/narrow layouts)
.tg-widget-calendar-mini {
.mini-calendar-container {
display: flex;
flex-direction: column;
width: 100%;
padding-bottom: 8px;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin-bottom: 6px;
}
.calendar-title {
font-weight: 600;
font-size: 12px;
display: flex;
gap: 4px;
}
.calendar-month {
color: var(--text-normal);
}
.calendar-year {
color: var(--text-muted);
}
.calendar-nav {
display: flex;
align-items: center;
gap: 4px;
}
.calendar-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: transparent;
cursor: pointer;
color: var(--text-muted);
transition: all 0.15s ease;
&:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
}
.calendar-today-btn {
padding: 2px 8px;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 10px;
color: var(--text-muted);
transition: all 0.15s ease;
&:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
padding: 0 4px;
}
.calendar-day-header {
text-align: center;
font-size: 10px;
color: var(--text-muted);
padding: 2px 0;
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: 2px;
&.calendar-weekend {
color: var(--text-accent);
}
}
.calendar-day {
border-radius: 4px;
padding: 1px;
cursor: pointer;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
transition: background 0.15s ease;
min-height: 28px;
&:hover {
background: var(--background-modifier-hover);
}
&.selected {
background: var(--background-modifier-border-hover);
}
&.today {
background: var(--interactive-accent);
color: var(--text-on-accent);
.calendar-day-number {
color: inherit;
}
}
&.past-due {
.calendar-day-number {
color: var(--color-red);
}
}
&.other-month {
opacity: 0.4;
}
}
.calendar-day-number {
text-align: center;
font-size: 11px;
font-weight: 500;
padding: 1px;
color: var(--text-normal);
}
.calendar-day-count {
background: var(--background-modifier-border);
color: var(--text-normal);
border-radius: 6px;
font-size: 9px;
padding: 0 4px;
margin-top: 1px;
text-align: center;
min-width: 14px;
&.has-priority {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
}
}
// Mini tasks list
.tg-widget-calendar-mini-tasks {
flex: 1;
overflow: auto;
border-top: 1px solid var(--background-modifier-border);
padding-top: 8px;
}
// Default visibility (wide/medium - show full calendar)
.tg-widget-calendar-full {
display: flex;
}
.tg-widget-calendar-mini,
.tg-widget-calendar-mini-tasks {
display: none;
}
// Layout mode visibility - compact/narrow shows mini calendar
.tg-widget-shell.tg-widget-compact,
.tg-widget-shell.tg-widget-narrow {
.tg-widget-calendar-full {
display: none !important;
}
.tg-widget-calendar-mini,
.tg-widget-calendar-mini-tasks {
display: block !important;
}
}
.tg-widget-shell.tg-widget-medium,
.tg-widget-shell.tg-widget-wide {
.tg-widget-calendar-mini,
.tg-widget-calendar-mini-tasks {
display: none !important;
}
.tg-widget-calendar-full {
display: flex !important;
}
}
// ============================================
// Tasks Widget
// ============================================
.tg-tasks-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.tg-tasks-toolbar-label {
font-size: 11px;
color: var(--text-muted);
}
/* .tg-tasks-group-select {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
color: var(--text-normal);
} */
.tg-tasks-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.tg-tasks-group {
border-radius: 6px;
overflow: hidden;
}
.tg-tasks-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--background-secondary);
cursor: pointer;
user-select: none;
border-radius: 6px;
&:hover {
background: var(--background-modifier-hover);
}
}
.tg-tasks-group-chevron {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--text-muted);
}
.tg-tasks-group-title {
font-size: 12px;
font-weight: 500;
color: var(--text-normal);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tg-tasks-group-count {
font-size: 11px;
color: var(--text-muted);
background: var(--background-primary);
padding: 1px 6px;
border-radius: 10px;
}
.tg-tasks-group-list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
}
.tg-tasks-item {
display: grid;
grid-template-columns: 18px 1fr;
align-items: start;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
&:hover {
background: var(--background-modifier-hover);
}
}
.tg-tasks-item-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tg-tasks-item-title {
font-size: 12px;
color: var(--text-normal);
cursor: pointer;
word-wrap: break-word;
&:hover {
color: var(--text-accent);
}
}
.tg-tasks-item-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tg-tasks-item-due {
font-size: 10px;
color: var(--text-muted);
&.is-overdue {
color: var(--color-red);
}
}
.tg-tasks-item-priority {
font-size: 10px;
}
// ============================================
// Projects Widget
// ============================================
.tg-projects-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.tg-projects-grid {
display: grid;
gap: 8px;
&.tg-projects-grid-wide {
grid-template-columns: repeat(2, 1fr);
}
&.tg-projects-grid-medium {
grid-template-columns: repeat(2, 1fr);
}
&.tg-projects-grid-narrow,
&.tg-projects-grid-compact {
grid-template-columns: 1fr;
}
}
.tg-projects-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
background: var(--background-secondary);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
border: 1px solid transparent;
&:hover {
background: var(--background-modifier-hover);
}
&.is-selected {
border-color: var(--interactive-accent);
background: var(--background-modifier-hover);
}
}
.tg-projects-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tg-projects-card-name {
font-size: 12px;
font-weight: 500;
color: var(--text-normal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.tg-projects-card-overdue {
font-size: 10px;
color: var(--color-red);
white-space: nowrap;
}
.tg-projects-card-progress {
width: 100%;
}
.tg-projects-progress-track {
height: 4px;
background: var(--background-primary);
border-radius: 2px;
overflow: hidden;
}
.tg-projects-progress-fill {
height: 100%;
background: var(--interactive-accent);
border-radius: 2px;
transition: width 0.3s ease;
&.is-complete {
background: var(--color-green);
}
&.has-overdue {
background: var(--color-orange);
}
}
.tg-projects-card-stats {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tg-projects-card-stat {
font-size: 10px;
color: var(--text-muted);
}
.tg-projects-card-percent {
font-size: 10px;
font-weight: 500;
color: var(--text-normal);
}
// ============================================
// Kanban Widget
// ============================================
.tg-kanban-container {
height: 100%;
min-height: 0;
}
.tg-kanban-board {
display: flex;
gap: 10px;
height: 100%;
min-height: 200px;
&.is-horizontal {
flex-direction: row;
overflow-x: auto;
}
&.is-vertical {
flex-direction: column;
overflow-y: auto;
}
}
.tg-kanban-column {
display: flex;
flex-direction: column;
min-width: 180px;
max-width: 280px;
flex: 1;
background: var(--background-secondary);
border-radius: 8px;
overflow: hidden;
.is-vertical & {
max-width: none;
min-width: 0;
}
}
.tg-kanban-column-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--background-modifier-border);
}
.tg-kanban-column-title {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tg-kanban-column-count {
font-size: 10px;
color: var(--text-faint);
background: var(--background-primary);
padding: 1px 6px;
border-radius: 10px;
}
.tg-kanban-column-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
flex: 1;
overflow-y: auto;
min-height: 60px;
.is-vertical & {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
}
}
.tg-kanban-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
background: var(--background-primary);
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
cursor: grab;
transition:
box-shadow 0.15s ease,
transform 0.15s ease;
.is-vertical & {
min-width: 140px;
flex-shrink: 0;
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:active {
cursor: grabbing;
}
}
.tg-kanban-card-ghost {
opacity: 0.4;
}
.tg-kanban-card-chosen {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: rotate(2deg);
}
.tg-kanban-card-drag {
opacity: 1;
}
.tg-kanban-card-content {
cursor: pointer;
}
.tg-kanban-card-title {
font-size: 12px;
color: var(--text-normal);
word-wrap: break-word;
line-height: 1.4;
&:hover {
color: var(--text-accent);
}
}
.tg-kanban-card-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tg-kanban-card-project {
font-size: 10px;
color: var(--text-muted);
background: var(--background-secondary);
padding: 1px 5px;
border-radius: 3px;
}
.tg-kanban-card-due {
font-size: 10px;
color: var(--text-muted);
&.is-overdue {
color: var(--color-red);
}
}
.tg-kanban-card-tags {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.tg-kanban-card-tag {
font-size: 9px;
color: var(--text-muted);
background: var(--background-secondary);
padding: 1px 4px;
border-radius: 3px;
&.tg-kanban-card-tag-more {
color: var(--text-faint);
}
}

View file

@ -0,0 +1,368 @@
import {
MarkdownPostProcessorContext,
MarkdownRenderChild,
parseYaml,
App,
} from "obsidian";
import TaskProgressBarPlugin from "@/index";
import {
TasksWidgetRenderer,
type TasksWidgetState,
type TasksGroupByMode,
type TasksFilterConfig,
} from "../renderers/TasksWidgetRenderer";
import {
KanbanWidgetRenderer,
type KanbanWidgetState,
type KanbanColumnConfig,
} from "../renderers/KanbanWidgetRenderer";
import {
HabitWidgetRenderer,
type HabitWidgetState,
type HabitFilterConfig,
} from "../renderers/HabitWidgetRenderer";
import type { WidgetRendererConfig } from "../renderers/BaseWidgetRenderer";
/**
* Supported widget types for codeblock
*/
type WidgetType =
| "tasks"
| "kanban"
| "habit"
| "projects"
| "calendar"
| "forecast";
/**
* Base codeblock configuration
*/
interface BaseCodeBlockConfig {
type: WidgetType;
title?: string;
height?: number;
maxHeight?: number;
}
/**
* Tasks widget codeblock configuration
*/
interface TasksCodeBlockConfig extends BaseCodeBlockConfig {
type: "tasks";
groupBy?: TasksGroupByMode;
filter?: TasksFilterConfig;
showToolbar?: boolean;
}
/**
* Kanban widget codeblock configuration
*/
interface KanbanCodeBlockConfig extends BaseCodeBlockConfig {
type: "kanban";
columns?: KanbanColumnConfig[];
filter?: {
tags?: string[];
projects?: string[];
query?: string;
};
enableDragDrop?: boolean;
}
/**
* Habit widget codeblock configuration
*/
interface HabitCodeBlockConfig extends BaseCodeBlockConfig {
type: "habit";
filter?: HabitFilterConfig;
showCreateButton?: boolean;
}
/**
* Union of all codeblock configurations
*/
type CodeBlockConfig =
| TasksCodeBlockConfig
| KanbanCodeBlockConfig
| HabitCodeBlockConfig
| BaseCodeBlockConfig;
/**
* Wrapper to manage renderer lifecycle within markdown context
*/
class RendererWrapper extends MarkdownRenderChild {
private renderer:
| TasksWidgetRenderer
| KanbanWidgetRenderer
| HabitWidgetRenderer
| null = null;
constructor(
containerEl: HTMLElement,
private createRenderer: () =>
| TasksWidgetRenderer
| KanbanWidgetRenderer
| HabitWidgetRenderer,
) {
super(containerEl);
}
override onload(): void {
this.renderer = this.createRenderer();
this.renderer.load();
}
override onunload(): void {
this.renderer?.unload();
this.renderer = null;
}
}
/**
* WidgetCodeBlockProcessor
*
* Processes `task-genius` codeblocks and renders widgets inline in notes.
*
* Usage:
* ```task-genius
* type: tasks
* groupBy: project
* filter:
* tags: ["#work"]
* ```
*/
export class WidgetCodeBlockProcessor {
constructor(
private app: App,
private plugin: TaskProgressBarPlugin,
) {}
/**
* Process a codeblock and render the appropriate widget
*/
async process(
source: string,
el: HTMLElement,
ctx: MarkdownPostProcessorContext,
): Promise<void> {
try {
const config = this.parseConfig(source);
if (!config.type) {
this.renderError(el, "Missing required 'type' field");
return;
}
// Create container with embedded widget styling
const container = el.createDiv({
cls: "tg-embedded-widget",
});
// Common renderer config
const rendererConfig: WidgetRendererConfig = {
showHeader: Boolean(config.title),
title: config.title,
isEmbedded: true,
height: config.height,
maxHeight: config.maxHeight ?? 400,
};
switch (config.type) {
case "tasks":
this.renderTasksWidget(
container,
config as TasksCodeBlockConfig,
rendererConfig,
ctx,
);
break;
case "kanban":
this.renderKanbanWidget(
container,
config as KanbanCodeBlockConfig,
rendererConfig,
ctx,
);
break;
case "habit":
this.renderHabitWidget(
container,
config as HabitCodeBlockConfig,
rendererConfig,
ctx,
);
break;
case "projects":
case "calendar":
case "forecast":
this.renderPlaceholder(container, config.type);
break;
default:
this.renderError(
el,
`Unknown widget type: ${(config as any).type}`,
);
}
} catch (error) {
this.renderError(
el,
`Failed to parse configuration: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Parse YAML configuration from codeblock source
*/
private parseConfig(source: string): CodeBlockConfig {
if (!source.trim()) {
return { type: "tasks" }; // Default to tasks widget
}
const parsed = parseYaml(source);
if (!parsed || typeof parsed !== "object") {
throw new Error("Invalid YAML configuration");
}
return parsed as CodeBlockConfig;
}
/**
* Render Tasks widget
*/
private renderTasksWidget(
container: HTMLElement,
config: TasksCodeBlockConfig,
rendererConfig: WidgetRendererConfig,
ctx: MarkdownPostProcessorContext,
): void {
const state: TasksWidgetState = {
groupBy: config.groupBy ?? "tag",
collapsedGroups: [],
filter: config.filter,
showToolbar: config.showToolbar ?? false,
};
const wrapper = new RendererWrapper(
container,
() =>
new TasksWidgetRenderer(this.app, {
containerEl: container,
plugin: this.plugin,
initialState: state,
config: rendererConfig,
}),
);
// Register for cleanup when the section is unloaded
ctx.addChild(wrapper);
}
/**
* Render Kanban widget
*/
private renderKanbanWidget(
container: HTMLElement,
config: KanbanCodeBlockConfig,
rendererConfig: WidgetRendererConfig,
ctx: MarkdownPostProcessorContext,
): void {
const state: KanbanWidgetState = {
columns: config.columns,
filter: config.filter,
// Disable drag-drop in embedded mode by default (can be overridden)
enableDragDrop: config.enableDragDrop ?? false,
};
const wrapper = new RendererWrapper(
container,
() =>
new KanbanWidgetRenderer(this.app, {
containerEl: container,
plugin: this.plugin,
initialState: state,
config: rendererConfig,
}),
);
ctx.addChild(wrapper);
}
/**
* Render Habit widget
*/
private renderHabitWidget(
container: HTMLElement,
config: HabitCodeBlockConfig,
rendererConfig: WidgetRendererConfig,
ctx: MarkdownPostProcessorContext,
): void {
const state: HabitWidgetState = {
filter: config.filter,
showCreateButton: config.showCreateButton ?? false,
};
const wrapper = new RendererWrapper(
container,
() =>
new HabitWidgetRenderer(this.app, {
containerEl: container,
plugin: this.plugin,
initialState: state,
config: rendererConfig,
}),
);
ctx.addChild(wrapper);
}
/**
* Render placeholder for not-yet-implemented widget types
*/
private renderPlaceholder(container: HTMLElement, type: string): void {
container.addClass("tg-embedded-widget-placeholder");
container.createDiv({
cls: "tg-embedded-widget-placeholder-text",
text: `${type.charAt(0).toUpperCase() + type.slice(1)} widget coming soon`,
});
}
/**
* Render error message
*/
private renderError(el: HTMLElement, message: string): void {
const errorEl = el.createDiv({
cls: "tg-embedded-widget-error",
});
errorEl.createSpan({
cls: "tg-embedded-widget-error-icon",
text: "⚠️",
});
errorEl.createSpan({
cls: "tg-embedded-widget-error-message",
text: message,
});
}
}
/**
* Register the codeblock processor with the plugin
*/
export function registerWidgetCodeBlock(plugin: TaskProgressBarPlugin): void {
const processor = new WidgetCodeBlockProcessor(plugin.app, plugin);
plugin.registerMarkdownCodeBlockProcessor(
"task-genius",
(source, el, ctx) => {
return processor.process(source, el, ctx);
},
);
// Also register short alias
plugin.registerMarkdownCodeBlockProcessor("tg", (source, el, ctx) => {
return processor.process(source, el, ctx);
});
}

View file

@ -0,0 +1,192 @@
import {
ItemView,
WorkspaceLeaf,
ViewStateResult,
debounce,
} from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { globalFilterContext, GlobalFilterState } from "./GlobalFilterContext";
import { WidgetShell } from "./WidgetShell";
export type WidgetLayoutMode = "compact" | "narrow" | "medium" | "wide";
export abstract class BaseWidgetView<
TState extends Record<string, unknown> & { linked?: boolean },
> extends ItemView {
protected shell?: WidgetShell;
protected state: TState;
private resizeObserver?: ResizeObserver;
private layoutMode: WidgetLayoutMode = "wide";
private isPersistingState = false;
private globalFilterUnsubscribe?: () => void;
private debouncedPersistState = debounce(() => {
void this.persistLeafState();
}, 300);
constructor(
leaf: WorkspaceLeaf,
protected plugin: TaskProgressBarPlugin,
defaultState: TState,
) {
super(leaf);
this.state = defaultState;
}
override async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass("tg-widget-view");
this.shell = new WidgetShell(this.contentEl, {
title: this.getDisplayText(),
linkable: this.isLinkable(),
linked: Boolean(this.state.linked),
onToggleLinked: (linked) => {
this.setWidgetState({ linked } as Partial<TState>);
},
});
this.addChild(this.shell);
this.shell.setLinked(Boolean(this.state.linked));
this.setupResizeObserver(this.shell.containerEl);
this.refreshGlobalFilterSubscription();
await this.render();
}
override async onClose(): Promise<void> {
this.teardownResizeObserver();
this.globalFilterUnsubscribe?.();
this.globalFilterUnsubscribe = undefined;
this.contentEl.empty();
}
getState(): TState {
return this.state;
}
override async setState(
state: Partial<TState>,
_result: ViewStateResult,
): Promise<void> {
this.state = { ...this.state, ...state } as TState;
this.shell?.setLinked(Boolean(this.state.linked));
this.refreshGlobalFilterSubscription();
await this.render();
}
protected setWidgetState(patch: Partial<TState>): void {
this.state = { ...this.state, ...patch } as TState;
this.shell?.setLinked(Boolean(this.state.linked));
this.refreshGlobalFilterSubscription();
this.debouncedPersistState();
void this.render();
}
updateWidgetState(patch: Partial<TState>): void {
this.setWidgetState(patch);
}
protected getLayoutMode(): WidgetLayoutMode {
return this.layoutMode;
}
protected isLinkable(): boolean {
return false;
}
protected onGlobalFilterChanged(_state: GlobalFilterState): void {}
protected abstract render(): Promise<void> | void;
protected getBodyEl(): HTMLElement {
if (!this.shell) {
throw new Error("WidgetShell not initialized");
}
return this.shell.bodyEl;
}
private refreshGlobalFilterSubscription(): void {
if (!this.isLinkable() || !this.state.linked) {
this.globalFilterUnsubscribe?.();
this.globalFilterUnsubscribe = undefined;
return;
}
if (this.globalFilterUnsubscribe) {
return;
}
this.globalFilterUnsubscribe = globalFilterContext.subscribe((state) => {
this.onGlobalFilterChanged(state);
});
}
private setupResizeObserver(target: HTMLElement): void {
this.teardownResizeObserver();
this.resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
const width = entry?.contentRect?.width ?? target.clientWidth;
this.applyLayoutMode(width);
});
this.resizeObserver.observe(target);
this.applyLayoutMode(target.clientWidth);
}
private teardownResizeObserver(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
}
private applyLayoutMode(width: number): void {
const nextMode: WidgetLayoutMode =
width < 300
? "compact"
: width < 400
? "narrow"
: width < 600
? "medium"
: "wide";
if (this.layoutMode === nextMode) {
return;
}
this.layoutMode = nextMode;
const el = this.shell?.containerEl;
if (!el) {
return;
}
el.removeClass("tg-widget-compact");
el.removeClass("tg-widget-narrow");
el.removeClass("tg-widget-medium");
el.removeClass("tg-widget-wide");
el.addClass(`tg-widget-${nextMode}`);
void this.render();
}
private async persistLeafState(): Promise<void> {
if (this.isPersistingState) {
return;
}
if (!this.leaf) {
return;
}
try {
this.isPersistingState = true;
const current = this.leaf.getViewState();
await this.leaf.setViewState(
{
...current,
type: this.getViewType(),
state: this.getState(),
},
{ replace: true },
);
} finally {
this.isPersistingState = false;
}
}
}

View file

@ -0,0 +1,35 @@
export interface GlobalFilterState {
tags?: string[];
projects?: string[];
contexts?: string[];
query?: string;
}
type GlobalFilterListener = (state: GlobalFilterState) => void;
export class GlobalFilterContext {
private state: GlobalFilterState = {};
private listeners = new Set<GlobalFilterListener>();
getState(): GlobalFilterState {
return this.state;
}
setState(patch: Partial<GlobalFilterState>): void {
this.state = { ...this.state, ...patch };
for (const listener of this.listeners) {
listener(this.state);
}
}
subscribe(listener: GlobalFilterListener): () => void {
this.listeners.add(listener);
listener(this.state);
return () => {
this.listeners.delete(listener);
};
}
}
export const globalFilterContext = new GlobalFilterContext();

View file

@ -0,0 +1,26 @@
import type { ItemView, WorkspaceLeaf } from "obsidian";
import type TaskProgressBarPlugin from "@/index";
export interface WidgetDefinition {
viewType: string;
displayName: string;
icon?: string;
commandId: string;
openLocation?: "right" | "left" | "tab";
create: (leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) => ItemView;
}
export class WidgetFactory {
private registry = new Map<string, WidgetDefinition>();
register(definition: WidgetDefinition): void {
if (this.registry.has(definition.viewType)) {
throw new Error(`Widget already registered: ${definition.viewType}`);
}
this.registry.set(definition.viewType, definition);
}
list(): WidgetDefinition[] {
return Array.from(this.registry.values());
}
}

View file

@ -0,0 +1,81 @@
import { Component, setIcon } from "obsidian";
export class WidgetShell extends Component {
public containerEl: HTMLElement;
public headerEl: HTMLElement;
public titleEl: HTMLElement;
public actionsEl: HTMLElement;
public bodyEl: HTMLElement;
private linkButtonEl?: HTMLElement;
private linked: boolean = false;
private onToggleLinked?: (linked: boolean) => void;
constructor(
parentEl: HTMLElement,
opts: {
title: string;
linkable?: boolean;
linked?: boolean;
onToggleLinked?: (linked: boolean) => void;
},
) {
super();
this.containerEl = parentEl.createDiv({ cls: "tg-widget-shell" });
this.headerEl = this.containerEl.createDiv({ cls: "tg-widget-header" });
this.titleEl = this.headerEl.createDiv({
cls: "tg-widget-title",
text: opts.title,
});
this.actionsEl = this.headerEl.createDiv({ cls: "tg-widget-actions" });
this.bodyEl = this.containerEl.createDiv({ cls: "tg-widget-body" });
this.linked = opts.linked ?? false;
this.onToggleLinked = opts.onToggleLinked;
if (opts.linkable) {
this.linkButtonEl = this.actionsEl.createDiv({
cls: "tg-widget-link-toggle",
});
this.registerDomEvent(this.linkButtonEl, "click", () => {
this.setLinked(!this.linked);
this.onToggleLinked?.(this.linked);
});
this.renderLinkIcon();
}
}
setTitle(title: string): void {
this.titleEl.setText(title);
}
setLinked(linked: boolean): void {
if (this.linked === linked) {
return;
}
this.linked = linked;
this.renderLinkIcon();
this.containerEl.toggleClass("tg-widget-linked", linked);
this.containerEl.toggleClass("tg-widget-unlinked", !linked);
}
getLinked(): boolean {
return this.linked;
}
private renderLinkIcon(): void {
if (!this.linkButtonEl) {
return;
}
this.linkButtonEl.setAttr(
"aria-label",
this.linked
? "Linked to global filter"
: "Unlinked from global filter",
);
// this.linkButtonEl.setAttr("title", this.linked ? "Linked" : "Unlinked");
this.linkButtonEl.empty();
setIcon(this.linkButtonEl, this.linked ? "link" : "unlink");
}
}

View file

@ -0,0 +1,13 @@
import { BaseWidgetView } from "./BaseWidgetView";
export function useWidgetState<
TState extends Record<string, unknown> & { linked?: boolean },
>(view: BaseWidgetView<TState>): {
get: () => TState;
set: (patch: Partial<TState>) => void;
} {
return {
get: () => view.getState(),
set: (patch) => view.updateWidgetState(patch),
};
}

View file

@ -0,0 +1,100 @@
import type TaskProgressBarPlugin from "@/index";
import type { WorkspaceLeaf } from "obsidian";
import { WidgetFactory } from "./core/WidgetFactory";
import {
FORECAST_WIDGET_VIEW_TYPE,
ForecastWidgetView,
} from "./views/ForecastWidgetView";
import {
CALENDAR_WIDGET_VIEW_TYPE,
CalendarWidgetView,
} from "./views/CalendarWidgetView";
import {
TASKS_WIDGET_VIEW_TYPE,
TasksWidgetView,
} from "./views/TasksWidgetView";
import {
PROJECTS_WIDGET_VIEW_TYPE,
ProjectsWidgetView,
} from "./views/ProjectsWidgetView";
import {
KANBAN_WIDGET_VIEW_TYPE,
KanbanWidgetView,
} from "./views/KanbanWidgetView";
const factory = new WidgetFactory();
factory.register({
viewType: FORECAST_WIDGET_VIEW_TYPE,
displayName: "Forecast Widget",
commandId: "open-task-genius-widget-forecast",
openLocation: "right",
create: (leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) =>
new ForecastWidgetView(leaf, plugin),
});
factory.register({
viewType: CALENDAR_WIDGET_VIEW_TYPE,
displayName: "Calendar Widget",
commandId: "open-task-genius-widget-calendar",
openLocation: "right",
create: (leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) =>
new CalendarWidgetView(leaf, plugin),
});
factory.register({
viewType: TASKS_WIDGET_VIEW_TYPE,
displayName: "Tasks Widget",
commandId: "open-task-genius-widget-tasks",
openLocation: "right",
create: (leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) =>
new TasksWidgetView(leaf, plugin),
});
factory.register({
viewType: PROJECTS_WIDGET_VIEW_TYPE,
displayName: "Projects Widget",
commandId: "open-task-genius-widget-projects",
openLocation: "right",
create: (leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) =>
new ProjectsWidgetView(leaf, plugin),
});
factory.register({
viewType: KANBAN_WIDGET_VIEW_TYPE,
displayName: "Kanban Widget",
commandId: "open-task-genius-widget-kanban",
openLocation: "right",
create: (leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) =>
new KanbanWidgetView(leaf, plugin),
});
export function registerWidgetViews(plugin: TaskProgressBarPlugin): void {
for (const widget of factory.list()) {
plugin.registerView(widget.viewType, (leaf) =>
widget.create(leaf, plugin),
);
}
}
export function registerWidgetCommands(plugin: TaskProgressBarPlugin): void {
for (const widget of factory.list()) {
plugin.addCommand({
id: widget.commandId,
name: `Task Genius: Open ${widget.displayName}`,
callback: async () => {
const { workspace } = plugin.app;
const leaf =
(widget.openLocation === "left"
? workspace.getLeftLeaf(true)
: widget.openLocation === "tab"
? workspace.getLeaf("tab")
: workspace.getRightLeaf(true)) ??
workspace.getLeaf("tab");
await leaf.setViewState({ type: widget.viewType });
workspace.revealLeaf(leaf);
},
});
}
}

View file

@ -0,0 +1,190 @@
import { Component } from "obsidian";
import TaskProgressBarPlugin from "@/index";
export type WidgetLayoutMode = "compact" | "narrow" | "medium" | "wide";
export interface WidgetRendererConfig {
/** Show title header */
showHeader?: boolean;
/** Title text */
title?: string;
/** Is embedded in codeblock (affects interactions) */
isEmbedded?: boolean;
/** Fixed height in pixels (0 = auto) */
height?: number;
/** Max height in pixels (0 = unlimited) */
maxHeight?: number;
}
export interface WidgetRendererOptions<TState> {
containerEl: HTMLElement;
plugin: TaskProgressBarPlugin;
initialState: TState;
config?: WidgetRendererConfig;
}
/**
* Base class for widget renderers.
* Unlike BaseWidgetView (which extends ItemView for sidebar),
* this class can render into any container element.
*/
export abstract class BaseWidgetRenderer<
TState extends Record<string, unknown>,
> extends Component {
protected containerEl: HTMLElement;
protected plugin: TaskProgressBarPlugin;
protected state: TState;
protected config: WidgetRendererConfig;
protected rootEl!: HTMLElement;
protected headerEl?: HTMLElement;
protected bodyEl!: HTMLElement;
protected layoutMode: WidgetLayoutMode = "wide";
private resizeObserver?: ResizeObserver;
constructor(options: WidgetRendererOptions<TState>) {
super();
this.containerEl = options.containerEl;
this.plugin = options.plugin;
this.state = options.initialState;
this.config = {
showHeader: false,
isEmbedded: false,
height: 0,
maxHeight: 400,
...options.config,
};
}
override onload(): void {
this.buildDOM();
this.setupResizeObserver();
this.applyLayoutMode(this.rootEl.clientWidth);
void this.render();
}
override onunload(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
this.rootEl?.remove();
}
/**
* Get current state
*/
getState(): TState {
return this.state;
}
/**
* Update state and re-render
*/
setState(patch: Partial<TState>): void {
this.state = { ...this.state, ...patch };
void this.render();
}
/**
* Get current layout mode
*/
getLayoutMode(): WidgetLayoutMode {
return this.layoutMode;
}
/**
* Get the body element for rendering content
*/
protected getBodyEl(): HTMLElement {
return this.bodyEl;
}
/**
* Check if running in embedded (codeblock) mode
*/
protected isEmbedded(): boolean {
return this.config.isEmbedded ?? false;
}
/**
* Abstract render method - implement in subclasses
*/
protected abstract render(): Promise<void> | void;
/**
* Build the basic DOM structure
*/
private buildDOM(): void {
this.rootEl = this.containerEl.createDiv({
cls: "tg-widget-renderer",
});
// Apply height constraints
if (this.config.height && this.config.height > 0) {
this.rootEl.style.height = `${this.config.height}px`;
}
if (this.config.maxHeight && this.config.maxHeight > 0) {
this.rootEl.style.maxHeight = `${this.config.maxHeight}px`;
}
// Optional header
if (this.config.showHeader && this.config.title) {
this.headerEl = this.rootEl.createDiv({
cls: "tg-widget-renderer-header",
});
this.headerEl.createSpan({
cls: "tg-widget-renderer-title",
text: this.config.title,
});
}
// Body container
this.bodyEl = this.rootEl.createDiv({
cls: "tg-widget-renderer-body",
});
}
/**
* Setup resize observer for responsive layout
*/
private setupResizeObserver(): void {
this.resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const width = entry.contentRect.width;
this.applyLayoutMode(width);
}
});
this.resizeObserver.observe(this.rootEl);
}
/**
* Apply layout mode based on container width
*/
private applyLayoutMode(width: number): void {
const nextMode: WidgetLayoutMode =
width < 300
? "compact"
: width < 400
? "narrow"
: width < 600
? "medium"
: "wide";
if (this.layoutMode === nextMode) {
return;
}
this.layoutMode = nextMode;
this.rootEl.removeClass(
"tg-renderer-compact",
"tg-renderer-narrow",
"tg-renderer-medium",
"tg-renderer-wide",
);
this.rootEl.addClass(`tg-renderer-${nextMode}`);
// Re-render on layout change
void this.render();
}
}

View file

@ -0,0 +1,72 @@
import { App } from "obsidian";
import {
BaseWidgetRenderer,
type WidgetRendererOptions,
} from "./BaseWidgetRenderer";
import { Habit } from "@/components/features/habit/habit";
export interface HabitFilterConfig {
/** Filter by habit names */
names?: string[];
/** Filter by habit types */
types?: ("daily" | "count" | "scheduled" | "mapping")[];
}
export interface HabitWidgetState {
/** Filter configuration */
filter?: HabitFilterConfig;
/** Whether to show the create button */
showCreateButton?: boolean;
[key: string]: unknown;
}
const DEFAULT_STATE: HabitWidgetState = {
showCreateButton: false,
};
/**
* HabitWidgetRenderer - Renders habit tracking cards
* Can be used in both sidebar and embedded codeblock
*/
export class HabitWidgetRenderer extends BaseWidgetRenderer<HabitWidgetState> {
private app: App;
private habitComponent?: Habit;
constructor(app: App, options: WidgetRendererOptions<HabitWidgetState>) {
super({
...options,
initialState: { ...DEFAULT_STATE, ...options.initialState },
});
this.app = app;
}
protected render(): void {
const bodyEl = this.getBodyEl();
bodyEl.empty();
// Clean up previous habit component
if (this.habitComponent) {
this.removeChild(this.habitComponent);
this.habitComponent = undefined;
}
// Apply embedded-specific styling
if (this.isEmbedded()) {
bodyEl.addClass("tg-habit-embedded");
}
// Instantiate the existing Habit component
this.habitComponent = new Habit(this.plugin, bodyEl);
// Register as child for lifecycle management
this.addChild(this.habitComponent);
}
override onunload(): void {
if (this.habitComponent) {
this.removeChild(this.habitComponent);
this.habitComponent = undefined;
}
super.onunload();
}
}

View file

@ -0,0 +1,361 @@
import { Notice, TFile, App } from "obsidian";
import Sortable from "sortablejs";
import type { Task } from "@/types/task";
import {
BaseWidgetRenderer,
type WidgetRendererOptions,
} from "./BaseWidgetRenderer";
export interface KanbanColumnConfig {
id: string;
label: string;
statusChars: string[];
}
export interface KanbanFilterConfig {
tags?: string[];
projects?: string[];
query?: string;
}
export interface KanbanWidgetState {
columns?: KanbanColumnConfig[];
filter?: KanbanFilterConfig;
enableDragDrop?: boolean;
[key: string]: unknown;
}
const DEFAULT_COLUMNS: KanbanColumnConfig[] = [
{ id: "todo", label: "To Do", statusChars: [" "] },
{ id: "doing", label: "In Progress", statusChars: ["/"] },
{ id: "done", label: "Done", statusChars: ["x", "X"] },
];
const DEFAULT_STATE: KanbanWidgetState = {
enableDragDrop: true,
};
/**
* KanbanWidgetRenderer - Renders a kanban board widget
* Can be used in both sidebar (via KanbanWidgetView) and embedded codeblock
*/
export class KanbanWidgetRenderer extends BaseWidgetRenderer<KanbanWidgetState> {
private app: App;
private boardContainerEl?: HTMLElement;
private tasks: Task[] = [];
private filteredTasks: Task[] = [];
private sortableInstances: Sortable[] = [];
constructor(app: App, options: WidgetRendererOptions<KanbanWidgetState>) {
super({
...options,
initialState: { ...DEFAULT_STATE, ...options.initialState },
});
this.app = app;
}
override onunload(): void {
this.destroySortables();
super.onunload();
}
protected async render(): Promise<void> {
const bodyEl = this.getBodyEl();
if (!this.boardContainerEl) {
bodyEl.empty();
this.boardContainerEl = bodyEl.createDiv({
cls: "tg-kanban-container",
});
}
await this.refreshTasks();
}
/**
* Set tasks externally
*/
setTasks(tasks: Task[]): void {
this.tasks = tasks;
this.applyFilter();
this.renderBoard();
}
/**
* Apply external filter state
*/
applyExternalFilter(filter: {
tags?: string[];
projects?: string[];
query?: string;
}): void {
const merged: KanbanFilterConfig = {
...this.state.filter,
...filter,
};
this.setState({ filter: merged });
}
/**
* Get configured columns
*/
private getColumns(): KanbanColumnConfig[] {
return this.state.columns ?? DEFAULT_COLUMNS;
}
private async refreshTasks(): Promise<void> {
const queryAPI = this.plugin.dataflowOrchestrator?.getQueryAPI();
if (!queryAPI) {
this.boardContainerEl?.empty();
this.boardContainerEl?.createDiv({
cls: "tg-widget-empty",
text: "Dataflow is not available",
});
return;
}
this.tasks = await queryAPI.getAllTasks();
this.applyFilter();
this.renderBoard();
}
private applyFilter(): void {
let filtered = [...this.tasks];
const filter = this.state.filter;
if (filter?.tags && filter.tags.length > 0) {
filtered = filtered.filter((task) =>
filter.tags!.some((tag) => task.metadata.tags?.includes(tag)),
);
}
if (filter?.projects && filter.projects.length > 0) {
filtered = filtered.filter((task) =>
filter.projects!.includes(task.metadata.project ?? ""),
);
}
if (filter?.query && filter.query.trim()) {
const q = filter.query.toLowerCase();
filtered = filtered.filter((task) =>
task.content.toLowerCase().includes(q),
);
}
this.filteredTasks = filtered;
}
private renderBoard(): void {
if (!this.boardContainerEl) return;
this.destroySortables();
this.boardContainerEl.empty();
const layoutMode = this.getLayoutMode();
const isVertical = layoutMode === "compact" || layoutMode === "narrow";
const boardEl = this.boardContainerEl.createDiv({
cls: `tg-kanban-board ${isVertical ? "is-vertical" : "is-horizontal"}`,
});
for (const column of this.getColumns()) {
this.renderColumn(boardEl, column);
}
}
private renderColumn(board: HTMLElement, column: KanbanColumnConfig): void {
const columnTasks = this.getTasksForColumn(column);
const columnEl = board.createDiv({
cls: "tg-kanban-column",
attr: { "data-column-id": column.id },
});
const headerEl = columnEl.createDiv({ cls: "tg-kanban-column-header" });
headerEl.createSpan({
cls: "tg-kanban-column-title",
text: column.label,
});
headerEl.createSpan({
cls: "tg-kanban-column-count",
text: `${columnTasks.length}`,
});
const listEl = columnEl.createDiv({
cls: "tg-kanban-column-list",
attr: { "data-column-id": column.id },
});
for (const task of columnTasks) {
this.renderCard(listEl, task);
}
// Only enable drag-drop if configured and not in embedded mode
// (or if explicitly enabled for embedded)
const enableDrag =
this.state.enableDragDrop !== false && !this.isEmbedded();
if (enableDrag) {
const sortable = new Sortable(listEl, {
group: "kanban-tasks",
animation: 150,
ghostClass: "tg-kanban-card-ghost",
chosenClass: "tg-kanban-card-chosen",
dragClass: "tg-kanban-card-drag",
onEnd: (evt) => {
this.handleDragEnd(evt);
},
});
this.sortableInstances.push(sortable);
}
}
private getTasksForColumn(column: KanbanColumnConfig): Task[] {
return this.filteredTasks.filter((task) => {
if (column.id === "done") {
return task.completed;
}
return column.statusChars.includes(task.status);
});
}
private renderCard(container: HTMLElement, task: Task): void {
const cardEl = container.createDiv({
cls: "tg-kanban-card",
attr: { "data-task-id": task.id },
});
const contentEl = cardEl.createDiv({ cls: "tg-kanban-card-content" });
contentEl.createDiv({
cls: "tg-kanban-card-title",
text: task.content,
});
const metaEl = cardEl.createDiv({ cls: "tg-kanban-card-meta" });
if (task.metadata.project) {
metaEl.createSpan({
cls: "tg-kanban-card-project",
text: task.metadata.project,
});
}
if (task.metadata.dueDate) {
const dueDate = new Date(task.metadata.dueDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
const isOverdue = dueDate < now && !task.completed;
metaEl.createSpan({
cls: `tg-kanban-card-due ${isOverdue ? "is-overdue" : ""}`,
text: this.formatDate(dueDate),
});
}
if (task.metadata.tags && task.metadata.tags.length > 0) {
const tagsEl = cardEl.createDiv({ cls: "tg-kanban-card-tags" });
for (const tag of task.metadata.tags.slice(0, 3)) {
tagsEl.createSpan({
cls: "tg-kanban-card-tag",
text: tag.replace(/^#/, ""),
});
}
if (task.metadata.tags.length > 3) {
tagsEl.createSpan({
cls: "tg-kanban-card-tag tg-kanban-card-tag-more",
text: `+${task.metadata.tags.length - 3}`,
});
}
}
this.registerDomEvent(contentEl, "click", async () => {
await this.openTask(task);
});
}
private formatDate(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const diff = Math.floor(
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
if (diff < -1) return `${Math.abs(diff)}d ago`;
if (diff < 7) return `In ${diff}d`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
private async handleDragEnd(evt: Sortable.SortableEvent): Promise<void> {
const taskId = evt.item.dataset.taskId;
const targetColumnId = (evt.to as HTMLElement).dataset.columnId;
if (!taskId || !targetColumnId) return;
const task = this.tasks.find((t) => t.id === taskId);
if (!task) return;
const columns = this.getColumns();
const targetColumn = columns.find((c) => c.id === targetColumnId);
if (!targetColumn) return;
const newCompleted = targetColumn.id === "done";
const newStatus = targetColumn.statusChars[0];
if (task.completed === newCompleted && task.status === newStatus) {
return;
}
await this.updateTaskStatus(task, newStatus, newCompleted);
}
private async updateTaskStatus(
task: Task,
newStatus: string,
completed: boolean,
): Promise<void> {
if (!this.plugin.writeAPI) {
new Notice("Write API not available");
return;
}
const result = await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
completed,
status: newStatus,
});
if (!result.success) {
new Notice(result.error ?? "Failed to update task");
void this.refreshTasks();
}
}
private async openTask(task: Task): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) {
return;
}
await this.app.workspace.getLeaf("tab").openFile(file);
}
private destroySortables(): void {
for (const sortable of this.sortableInstances) {
sortable.destroy();
}
this.sortableInstances = [];
}
}

View file

@ -0,0 +1,611 @@
import { Notice, TFile, setIcon, App } from "obsidian";
import type { Task } from "@/types/task";
import {
BaseWidgetRenderer,
type WidgetRendererOptions,
} from "./BaseWidgetRenderer";
export type TasksGroupByMode =
| "none"
| "tag"
| "project"
| "priority"
| "due"
| "status"
| "date";
export interface TasksFilterConfig {
/** Filter by tags */
tags?: string[];
/** Filter by projects */
projects?: string[];
/** Filter by status (completed, todo, etc.) */
status?: ("completed" | "todo" | "in-progress")[];
/** Text query filter */
query?: string;
/** Only show overdue tasks */
overdue?: boolean;
/** Only show tasks due within N days */
dueWithinDays?: number;
}
export interface TasksWidgetState {
groupBy: TasksGroupByMode;
collapsedGroups: string[];
filter?: TasksFilterConfig;
showToolbar?: boolean;
[key: string]: unknown;
}
const DEFAULT_STATE: TasksWidgetState = {
groupBy: "tag",
collapsedGroups: [],
showToolbar: true,
};
/**
* TasksWidgetRenderer - Renders a tasks list widget
* Can be used in both sidebar (via TasksWidgetView) and embedded codeblock
*/
export class TasksWidgetRenderer extends BaseWidgetRenderer<TasksWidgetState> {
private app: App;
private taskContainerEl?: HTMLElement;
private tasks: Task[] = [];
private filteredTasks: Task[] = [];
constructor(app: App, options: WidgetRendererOptions<TasksWidgetState>) {
super({
...options,
initialState: { ...DEFAULT_STATE, ...options.initialState },
});
this.app = app;
}
protected async render(): Promise<void> {
const bodyEl = this.getBodyEl();
if (!this.taskContainerEl) {
bodyEl.empty();
// Only show toolbar if not embedded or explicitly enabled
if (this.state.showToolbar && !this.isEmbedded()) {
this.renderToolbar(bodyEl);
}
this.taskContainerEl = bodyEl.createDiv({
cls: "tg-tasks-container",
});
}
await this.refreshTasks();
}
/**
* Set tasks externally (used by View wrapper)
*/
setTasks(tasks: Task[]): void {
this.tasks = tasks;
this.applyFilter();
this.renderTaskList();
}
/**
* Apply external filter state (from GlobalFilterContext)
*/
applyExternalFilter(filter: {
tags?: string[];
projects?: string[];
contexts?: string[];
query?: string;
}): void {
// Merge external filter with internal filter
const merged: TasksFilterConfig = {
...this.state.filter,
tags:
filter.tags && filter.tags.length > 0
? filter.tags
: this.state.filter?.tags,
projects:
filter.projects && filter.projects.length > 0
? filter.projects
: this.state.filter?.projects,
query: filter.query || this.state.filter?.query,
};
this.setState({ filter: merged });
}
private renderToolbar(parentEl: HTMLElement): void {
const toolbar = parentEl.createDiv({ cls: "tg-tasks-toolbar" });
toolbar.createSpan({
cls: "tg-tasks-toolbar-label",
text: "Group by:",
});
const select = toolbar.createEl("select", {
cls: "tg-tasks-group-select dropdown",
});
const options: { value: TasksGroupByMode; label: string }[] = [
{ value: "none", label: "None" },
{ value: "tag", label: "Tag" },
{ value: "project", label: "Project" },
{ value: "priority", label: "Priority" },
{ value: "due", label: "Due Date" },
{ value: "status", label: "Status" },
{ value: "date", label: "Date" },
];
for (const opt of options) {
const optionEl = select.createEl("option", {
value: opt.value,
text: opt.label,
});
if (opt.value === this.state.groupBy) {
optionEl.selected = true;
}
}
this.registerDomEvent(select, "change", () => {
this.setState({
groupBy: select.value as TasksGroupByMode,
collapsedGroups: [],
});
});
}
private async refreshTasks(): Promise<void> {
const queryAPI = this.plugin.dataflowOrchestrator?.getQueryAPI();
if (!queryAPI) {
this.taskContainerEl?.empty();
this.taskContainerEl?.createDiv({
cls: "tg-widget-empty",
text: "Dataflow is not available",
});
return;
}
this.tasks = await queryAPI.getAllTasks();
this.applyFilter();
this.renderTaskList();
}
private applyFilter(): void {
let filtered = [...this.tasks];
const filter = this.state.filter;
// Default: hide completed unless explicitly requested
if (!filter?.status || !filter.status.includes("completed")) {
filtered = filtered.filter((t) => !t.completed);
}
if (filter?.tags && filter.tags.length > 0) {
filtered = filtered.filter((task) =>
filter.tags!.some((tag) => task.metadata.tags?.includes(tag)),
);
}
if (filter?.projects && filter.projects.length > 0) {
filtered = filtered.filter((task) =>
filter.projects!.includes(task.metadata.project ?? ""),
);
}
if (filter?.query && filter.query.trim()) {
const q = filter.query.toLowerCase();
filtered = filtered.filter((task) =>
task.content.toLowerCase().includes(q),
);
}
if (filter?.overdue) {
const now = Date.now();
filtered = filtered.filter(
(task) => task.metadata.dueDate && task.metadata.dueDate < now,
);
}
if (filter?.dueWithinDays !== undefined && filter.dueWithinDays > 0) {
const now = new Date();
now.setHours(0, 0, 0, 0);
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + filter.dueWithinDays);
filtered = filtered.filter((task) => {
if (!task.metadata.dueDate) return false;
const due = new Date(task.metadata.dueDate);
return due >= now && due <= endDate;
});
}
this.filteredTasks = filtered;
}
private renderTaskList(): void {
if (!this.taskContainerEl) return;
this.taskContainerEl.empty();
if (this.filteredTasks.length === 0) {
this.taskContainerEl.createDiv({
cls: "tg-widget-empty",
text: "No tasks",
});
return;
}
const groups = this.groupTasks(this.filteredTasks);
const sortedGroupNames = this.sortGroupNames(Object.keys(groups));
for (const groupName of sortedGroupNames) {
this.renderGroup(
this.taskContainerEl,
groupName,
groups[groupName],
);
}
}
private groupTasks(tasks: Task[]): Record<string, Task[]> {
const groups: Record<string, Task[]> = {};
const mode = this.state.groupBy;
if (mode === "none") {
groups["All Tasks"] = tasks;
return groups;
}
for (const task of tasks) {
let keys: string[];
switch (mode) {
case "tag": {
const tags = task.metadata.tags;
keys = tags && tags.length > 0 ? tags : ["No Tag"];
break;
}
case "project": {
const project = task.metadata.project;
keys = [project ?? "No Project"];
break;
}
case "priority": {
const p = task.metadata.priority ?? 0;
keys = [this.getPriorityGroupName(p)];
break;
}
case "due": {
keys = [this.getDueGroupName(task.metadata.dueDate)];
break;
}
case "status": {
keys = [this.getStatusGroupName(task)];
break;
}
case "date": {
keys = [
this.getAbsoluteDateGroupName(task.metadata.dueDate),
];
break;
}
default:
keys = ["Other"];
}
for (const key of keys) {
if (!groups[key]) {
groups[key] = [];
}
if (!groups[key].some((t) => t.id === task.id)) {
groups[key].push(task);
}
}
}
return groups;
}
private sortGroupNames(names: string[]): string[] {
const mode = this.state.groupBy;
if (mode === "priority") {
const order = [
"Highest",
"High",
"Medium",
"Low",
"Lowest",
"No Priority",
];
return names.sort((a, b) => order.indexOf(a) - order.indexOf(b));
}
if (mode === "due") {
const order = [
"Overdue",
"Today",
"Tomorrow",
"This Week",
"Next Week",
"Later",
"No Due Date",
];
return names.sort((a, b) => order.indexOf(a) - order.indexOf(b));
}
if (mode === "status") {
const order = [
"In Progress",
"Todo",
"Planned",
"Completed",
"Cancelled",
"Other",
];
return names.sort((a, b) => {
const idxA = order.indexOf(a);
const idxB = order.indexOf(b);
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
if (idxA !== -1) return -1;
if (idxB !== -1) return 1;
return a.localeCompare(b);
});
}
if (mode === "date") {
// Sort by date descending (most recent first), with "No Due Date" at end
return names.sort((a, b) => {
if (a === "No Due Date") return 1;
if (b === "No Due Date") return -1;
// Dates are in YYYY-MM-DD format, so string comparison works
return b.localeCompare(a);
});
}
// Default: alphabetical with special groups at end
return names.sort((a, b) => {
const specialGroups = [
"Uncategorized",
"No Tag",
"No Project",
"No Priority",
"No Due Date",
];
const aIsSpecial = specialGroups.includes(a);
const bIsSpecial = specialGroups.includes(b);
if (aIsSpecial && !bIsSpecial) return 1;
if (!aIsSpecial && bIsSpecial) return -1;
return a.localeCompare(b);
});
}
private getPriorityGroupName(priority: number): string {
switch (priority) {
case 5:
return "Highest";
case 4:
return "High";
case 3:
return "Medium";
case 2:
return "Low";
case 1:
return "Lowest";
default:
return "No Priority";
}
}
private getDueGroupName(dueDate?: number): string {
if (!dueDate) return "No Due Date";
const now = new Date();
now.setHours(0, 0, 0, 0);
const due = new Date(dueDate);
due.setHours(0, 0, 0, 0);
const diffDays = Math.floor(
(due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays < 0) return "Overdue";
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Tomorrow";
if (diffDays <= 7) return "This Week";
if (diffDays <= 14) return "Next Week";
return "Later";
}
private getStatusGroupName(task: Task): string {
const statusChar = task.status || (task.completed ? "x" : " ");
const statuses = this.plugin.settings.taskStatuses;
const isInGroup = (group: string, char: string): boolean =>
group.split("|").includes(char);
if (isInGroup(statuses.inProgress, statusChar)) return "In Progress";
if (isInGroup(statuses.notStarted, statusChar)) return "Todo";
if (isInGroup(statuses.completed, statusChar)) return "Completed";
if (isInGroup(statuses.abandoned, statusChar)) return "Cancelled";
if (isInGroup(statuses.planned, statusChar)) return "Planned";
// Fallback based on countOtherStatusesAs setting
const otherMapping = this.plugin.settings.countOtherStatusesAs;
if (otherMapping === "inProgress") return "In Progress";
if (otherMapping === "completed") return "Completed";
if (otherMapping === "abandoned") return "Cancelled";
if (otherMapping === "planned") return "Planned";
if (otherMapping === "notStarted") return "Todo";
return "Other";
}
private getAbsoluteDateGroupName(dueDate?: number): string {
if (!dueDate) return "No Due Date";
const due = new Date(dueDate);
const year = due.getFullYear();
const month = String(due.getMonth() + 1).padStart(2, "0");
const day = String(due.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
private renderGroup(
container: HTMLElement,
name: string,
tasks: Task[],
): void {
const isCollapsed = this.state.collapsedGroups.includes(name);
const groupEl = container.createDiv({ cls: "tg-tasks-group" });
const headerEl = groupEl.createDiv({ cls: "tg-tasks-group-header" });
const chevronEl = headerEl.createSpan({
cls: "tg-tasks-group-chevron",
});
setIcon(chevronEl, isCollapsed ? "chevron-right" : "chevron-down");
headerEl.createSpan({
cls: "tg-tasks-group-title",
text: name,
});
headerEl.createSpan({
cls: "tg-tasks-group-count",
text: `${tasks.length}`,
});
this.registerDomEvent(headerEl, "click", () => {
this.toggleGroupCollapse(name);
});
if (!isCollapsed) {
const listEl = groupEl.createDiv({ cls: "tg-tasks-group-list" });
for (const task of tasks) {
this.renderTaskItem(listEl, task);
}
}
}
private toggleGroupCollapse(groupName: string): void {
const collapsed = [...this.state.collapsedGroups];
const index = collapsed.indexOf(groupName);
if (index >= 0) {
collapsed.splice(index, 1);
} else {
collapsed.push(groupName);
}
this.setState({ collapsedGroups: collapsed });
}
private renderTaskItem(container: HTMLElement, task: Task): void {
const itemEl = container.createDiv({ cls: "tg-tasks-item" });
const checkbox = itemEl.createEl("input", { type: "checkbox" });
checkbox.checked = Boolean(task.completed);
checkbox.addClass("task-list-item-checkbox");
this.registerDomEvent(checkbox, "change", async () => {
await this.toggleTaskCompletion(task, checkbox.checked);
});
const contentEl = itemEl.createDiv({ cls: "tg-tasks-item-content" });
const titleEl = contentEl.createDiv({
cls: "tg-tasks-item-title",
text: task.content,
});
this.registerDomEvent(titleEl, "click", async () => {
await this.openTask(task);
});
const metaEl = contentEl.createDiv({ cls: "tg-tasks-item-meta" });
if (task.metadata.dueDate) {
const dueDate = new Date(task.metadata.dueDate);
const isOverdue = dueDate < new Date();
metaEl.createSpan({
cls: `tg-tasks-item-due ${isOverdue ? "is-overdue" : ""}`,
text: this.formatDate(dueDate),
});
}
if (task.metadata.priority && task.metadata.priority > 0) {
metaEl.createSpan({
cls: `tg-tasks-item-priority priority-${task.metadata.priority}`,
text: this.getPriorityLabel(task.metadata.priority),
});
}
}
private formatDate(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const diff = Math.floor(
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
if (diff < -1) return `${Math.abs(diff)}d ago`;
if (diff < 7) return `In ${diff}d`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
private getPriorityLabel(priority: number): string {
switch (priority) {
case 5:
return "🔴";
case 4:
return "🟠";
case 3:
return "🟡";
case 2:
return "🔵";
case 1:
return "⚪";
default:
return "";
}
}
private async toggleTaskCompletion(
task: Task,
completed: boolean,
): Promise<void> {
if (!this.plugin.writeAPI) {
new Notice("Write API not available");
return;
}
const result = await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
completed,
});
if (!result.success) {
new Notice(result.error ?? "Failed to update task");
}
}
private async openTask(task: Task): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) {
return;
}
await this.app.workspace.getLeaf("tab").openFile(file);
}
}

View file

@ -0,0 +1,26 @@
export { BaseWidgetRenderer } from "./BaseWidgetRenderer";
export type {
WidgetLayoutMode,
WidgetRendererConfig,
WidgetRendererOptions,
} from "./BaseWidgetRenderer";
export { TasksWidgetRenderer } from "./TasksWidgetRenderer";
export type {
TasksWidgetState,
TasksGroupByMode,
TasksFilterConfig,
} from "./TasksWidgetRenderer";
export { KanbanWidgetRenderer } from "./KanbanWidgetRenderer";
export type {
KanbanWidgetState,
KanbanColumnConfig,
KanbanFilterConfig,
} from "./KanbanWidgetRenderer";
export { HabitWidgetRenderer } from "./HabitWidgetRenderer";
export type {
HabitWidgetState,
HabitFilterConfig,
} from "./HabitWidgetRenderer";

View file

@ -0,0 +1,256 @@
import { Notice, TFile, debounce } from "obsidian";
import type { WorkspaceLeaf } from "obsidian";
import type { Task } from "@/types/task";
import TaskProgressBarPlugin from "@/index";
import { BaseWidgetView } from "../core/BaseWidgetView";
import { Events, on } from "@/dataflow/events/Events";
import { isDataflowEnabled } from "@/dataflow/createDataflow";
import {
CalendarComponent as FullCalendarComponent,
type CalendarEvent,
} from "@/components/features/calendar";
import { CalendarComponent as MiniCalendarComponent } from "@/components/features/task/view/calendar";
export const CALENDAR_WIDGET_VIEW_TYPE = "task-genius-widget-calendar";
type CalendarWidgetState = {
linked: boolean;
viewMode: string;
selectedDate?: number;
};
export class CalendarWidgetView extends BaseWidgetView<CalendarWidgetState> {
private fullHostEl?: HTMLElement;
private miniHostEl?: HTMLElement;
private miniTasksEl?: HTMLElement;
private fullCalendar?: FullCalendarComponent;
private miniCalendar?: MiniCalendarComponent;
private tasks: Task[] = [];
private isRestoringMiniSelection = false;
private refreshScheduled = debounce(() => {
void this.refreshTasks();
}, 500);
constructor(leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) {
super(leaf, plugin, { linked: true, viewMode: "month" });
}
getViewType(): string {
return CALENDAR_WIDGET_VIEW_TYPE;
}
getDisplayText(): string {
return "Calendar Widget";
}
protected isLinkable(): boolean {
return true;
}
override async onOpen(): Promise<void> {
await super.onOpen();
if (
isDataflowEnabled(this.plugin) &&
this.plugin.dataflowOrchestrator
) {
this.registerEvent(
on(this.app, Events.TASK_CACHE_UPDATED, () => {
this.refreshScheduled();
}),
);
}
}
protected async render(): Promise<void> {
const bodyEl = this.getBodyEl();
const mode = this.getLayoutMode();
if (!this.fullHostEl) {
bodyEl.empty();
const container = bodyEl.createDiv({ cls: "tg-widget-calendar" });
this.fullHostEl = container.createDiv({
cls: "tg-widget-calendar-full",
});
this.miniHostEl = container.createDiv({
cls: "tg-widget-calendar-mini",
});
this.miniTasksEl = container.createDiv({
cls: "tg-widget-calendar-mini-tasks",
});
this.fullCalendar = new FullCalendarComponent(
this.app,
this.plugin,
this.fullHostEl,
[],
{
onTaskSelected: (task: Task | null) => {
if (task) {
void this.openTask(task);
}
},
onTaskCompleted: (task: Task) => {
void this.toggleTaskCompletion(task, !task.completed);
},
onEventContextMenu: (
ev: MouseEvent,
event: CalendarEvent,
) => {
ev.preventDefault();
const realTask = (event as any)?.metadata
?.originalTask as Task;
if (realTask) {
void this.openTask(realTask);
} else {
void this.openTask(event as unknown as Task);
}
},
persistViewMode: false,
onViewModeChange: (viewMode) => {
this.updateWidgetState({ viewMode });
},
},
"calendar",
);
this.addChild(this.fullCalendar);
this.fullCalendar.load();
this.miniCalendar = new MiniCalendarComponent(this.miniHostEl, {
showTaskCounts: true,
});
this.addChild(this.miniCalendar);
this.miniCalendar.onDateSelected = (date: Date, tasks: Task[]) => {
if (!this.isRestoringMiniSelection) {
this.updateWidgetState({ selectedDate: date.getTime() });
}
this.renderMiniTasks(date, tasks);
};
this.miniCalendar.load();
}
// Visibility is controlled by CSS based on layout mode class
// (.tg-widget-compact, .tg-widget-narrow, .tg-widget-medium, .tg-widget-wide)
await this.refreshTasks();
if (mode !== "compact" && mode !== "narrow") {
this.fullCalendar?.setView(this.state.viewMode as any);
}
if (this.state.selectedDate && this.miniCalendar) {
const date = new Date(this.state.selectedDate);
this.isRestoringMiniSelection = true;
try {
this.miniCalendar.selectDate(date);
const tasksForDate = this.getMiniTasksForDate(date);
this.renderMiniTasks(date, tasksForDate);
} finally {
this.isRestoringMiniSelection = false;
}
}
}
private async refreshTasks(): Promise<void> {
const queryAPI = this.plugin.dataflowOrchestrator?.getQueryAPI();
if (!queryAPI) {
this.fullHostEl?.empty();
this.fullHostEl?.createDiv({
cls: "tg-widget-empty",
text: "Dataflow is not available",
});
return;
}
this.tasks = await queryAPI.getAllTasks();
this.fullCalendar?.updateTasks(this.tasks);
this.miniCalendar?.setTasks(this.tasks);
}
private getMiniTasksForDate(date: Date): Task[] {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const startTs = start.getTime();
return this.tasks.filter((task) => {
if (!task.metadata?.dueDate) {
return false;
}
const due = new Date(task.metadata.dueDate);
due.setHours(0, 0, 0, 0);
return due.getTime() === startTs && !task.completed;
});
}
private renderMiniTasks(date: Date, tasks: Task[]): void {
if (!this.miniTasksEl) {
return;
}
this.miniTasksEl.empty();
this.miniTasksEl.createDiv({
cls: "tg-widget-section-title",
text: date.toDateString(),
});
if (tasks.length === 0) {
this.miniTasksEl.createDiv({
cls: "tg-widget-section-empty",
text: "No tasks",
});
return;
}
const listEl = this.miniTasksEl.createDiv({
cls: "tg-widget-task-list",
});
for (const task of tasks) {
const rowEl = listEl.createDiv({ cls: "tg-widget-task-row" });
const checkbox = rowEl.createEl("input", {
type: "checkbox",
});
checkbox.checked = Boolean(task.completed);
this.registerDomEvent(checkbox, "change", async () => {
await this.toggleTaskCompletion(task, checkbox.checked);
});
const titleEl = rowEl.createDiv({
cls: "tg-widget-task-title",
text: task.content,
});
this.registerDomEvent(titleEl, "click", async () => {
await this.openTask(task);
});
}
}
private async toggleTaskCompletion(
task: Task,
completed: boolean,
): Promise<void> {
if (!this.plugin.writeAPI) {
new Notice("Write API not available");
return;
}
const result = await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
completed,
});
if (!result.success) {
new Notice(result.error ?? "Failed to update task");
}
}
private async openTask(task: Task): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) {
return;
}
await this.app.workspace.getLeaf("tab").openFile(file);
}
}

View file

@ -0,0 +1,180 @@
import { Notice, TFile, WorkspaceLeaf, debounce } from "obsidian";
import type { Task } from "@/types/task";
import TaskProgressBarPlugin from "@/index";
import { BaseWidgetView } from "../core/BaseWidgetView";
import { Events, on } from "@/dataflow/events/Events";
import { isDataflowEnabled } from "@/dataflow/createDataflow";
export const FORECAST_WIDGET_VIEW_TYPE = "task-genius-widget-forecast";
type ForecastWidgetState = {
linked: boolean;
};
export class ForecastWidgetView extends BaseWidgetView<ForecastWidgetState> {
private taskContainerEl?: HTMLElement;
private refreshScheduled = debounce(() => {
void this.refresh();
}, 500);
constructor(leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) {
super(leaf, plugin, { linked: true });
}
getViewType(): string {
return FORECAST_WIDGET_VIEW_TYPE;
}
getDisplayText(): string {
return "Forecast Widget";
}
protected isLinkable(): boolean {
return true;
}
override async onOpen(): Promise<void> {
await super.onOpen();
if (isDataflowEnabled(this.plugin) && this.plugin.dataflowOrchestrator) {
this.registerEvent(
on(this.app, Events.TASK_CACHE_UPDATED, () => {
this.refreshScheduled();
}),
);
}
}
protected async render(): Promise<void> {
const bodyEl = this.getBodyEl();
if (!this.taskContainerEl) {
bodyEl.empty();
this.taskContainerEl = bodyEl.createDiv({
cls: "tg-widget-forecast",
});
}
await this.refresh();
}
private async refresh(): Promise<void> {
if (!this.taskContainerEl) {
return;
}
const queryAPI = this.plugin.dataflowOrchestrator?.getQueryAPI();
if (!queryAPI) {
this.taskContainerEl.empty();
this.taskContainerEl.createDiv({
cls: "tg-widget-empty",
text: "Dataflow is not available",
});
return;
}
const [overdue, today, tomorrow] = await Promise.all([
queryAPI.getOverdueTasks(),
queryAPI.getTasksDueToday(),
this.getTasksDueTomorrow(queryAPI),
]);
this.taskContainerEl.empty();
this.renderSection(this.taskContainerEl, "Overdue", overdue);
this.renderSection(
this.taskContainerEl,
"Today",
today.filter((task) => !task.completed),
);
this.renderSection(this.taskContainerEl, "Tomorrow", tomorrow);
}
private async getTasksDueTomorrow(queryAPI: {
getTasksByDateRange: (opts: {
from?: number;
to?: number;
field?: "due" | "start" | "scheduled";
}) => Promise<Task[]>;
}): Promise<Task[]> {
const start = new Date();
start.setHours(0, 0, 0, 0);
start.setDate(start.getDate() + 1);
const end = new Date(start);
end.setDate(end.getDate() + 1);
const tasks = await queryAPI.getTasksByDateRange({
from: start.getTime(),
to: end.getTime(),
field: "due",
});
return tasks.filter((task) => !task.completed);
}
private renderSection(
parentEl: HTMLElement,
title: string,
tasks: Task[],
): void {
const sectionEl = parentEl.createDiv({ cls: "tg-widget-section" });
sectionEl.createDiv({
cls: "tg-widget-section-title",
text: `${title} (${tasks.length})`,
});
if (tasks.length === 0) {
sectionEl.createDiv({
cls: "tg-widget-section-empty",
text: "No tasks",
});
return;
}
const listEl = sectionEl.createDiv({ cls: "tg-widget-task-list" });
for (const task of tasks) {
const rowEl = listEl.createDiv({ cls: "tg-widget-task-row" });
const checkbox = rowEl.createEl("input", {
type: "checkbox",
});
checkbox.checked = Boolean(task.completed);
this.registerDomEvent(checkbox, "change", async () => {
await this.toggleTaskCompletion(task, checkbox.checked);
});
const titleEl = rowEl.createDiv({
cls: "tg-widget-task-title",
text: task.content,
});
this.registerDomEvent(titleEl, "click", async () => {
await this.openTask(task);
});
}
}
private async toggleTaskCompletion(
task: Task,
completed: boolean,
): Promise<void> {
if (!this.plugin.writeAPI) {
new Notice("Write API not available");
return;
}
const result = await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
completed,
});
if (!result.success) {
new Notice(result.error ?? "Failed to update task");
}
await this.refresh();
}
private async openTask(task: Task): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) {
return;
}
await this.app.workspace.getLeaf("tab").openFile(file);
}
}

View file

@ -0,0 +1,361 @@
import { Notice, TFile, debounce } from "obsidian";
import type { WorkspaceLeaf } from "obsidian";
import Sortable from "sortablejs";
import type { Task } from "@/types/task";
import TaskProgressBarPlugin from "@/index";
import { BaseWidgetView } from "../core/BaseWidgetView";
import { Events, on } from "@/dataflow/events/Events";
import { isDataflowEnabled } from "@/dataflow/createDataflow";
import {
globalFilterContext,
type GlobalFilterState,
} from "../core/GlobalFilterContext";
export const KANBAN_WIDGET_VIEW_TYPE = "task-genius-widget-kanban";
interface KanbanColumn {
id: string;
label: string;
statusChars: string[];
}
const DEFAULT_COLUMNS: KanbanColumn[] = [
{ id: "todo", label: "To Do", statusChars: [" "] },
{ id: "doing", label: "In Progress", statusChars: ["/"] },
{ id: "done", label: "Done", statusChars: ["x", "X"] },
];
interface KanbanWidgetState {
linked: boolean;
[key: string]: unknown;
}
const DEFAULT_STATE: KanbanWidgetState = {
linked: true,
};
export class KanbanWidgetView extends BaseWidgetView<KanbanWidgetState> {
private boardContainerEl?: HTMLElement;
private tasks: Task[] = [];
private filteredTasks: Task[] = [];
private sortableInstances: Sortable[] = [];
private refreshScheduled = debounce(() => {
void this.refreshTasks();
}, 500);
constructor(leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) {
super(leaf, plugin, { ...DEFAULT_STATE });
}
getViewType(): string {
return KANBAN_WIDGET_VIEW_TYPE;
}
getDisplayText(): string {
return "Kanban Widget";
}
protected isLinkable(): boolean {
return true;
}
override async onOpen(): Promise<void> {
await super.onOpen();
if (
isDataflowEnabled(this.plugin) &&
this.plugin.dataflowOrchestrator
) {
this.registerEvent(
on(this.app, Events.TASK_CACHE_UPDATED, () => {
this.refreshScheduled();
}),
);
}
}
override async onClose(): Promise<void> {
this.destroySortables();
await super.onClose();
}
protected onGlobalFilterChanged(state: GlobalFilterState): void {
this.applyFilter(state);
this.renderBoard();
}
protected async render(): Promise<void> {
const bodyEl = this.getBodyEl();
if (!this.boardContainerEl) {
bodyEl.empty();
this.boardContainerEl = bodyEl.createDiv({
cls: "tg-kanban-container",
});
}
await this.refreshTasks();
}
private async refreshTasks(): Promise<void> {
const queryAPI = this.plugin.dataflowOrchestrator?.getQueryAPI();
if (!queryAPI) {
this.boardContainerEl?.empty();
this.boardContainerEl?.createDiv({
cls: "tg-widget-empty",
text: "Dataflow is not available",
});
return;
}
this.tasks = await queryAPI.getAllTasks();
if (this.state.linked) {
this.applyFilter(globalFilterContext.getState());
} else {
this.filteredTasks = [...this.tasks];
}
this.renderBoard();
}
private applyFilter(filter: GlobalFilterState): void {
let filtered = [...this.tasks];
if (filter.tags && filter.tags.length > 0) {
filtered = filtered.filter((task) =>
filter.tags!.some((tag) => task.metadata.tags?.includes(tag)),
);
}
if (filter.projects && filter.projects.length > 0) {
filtered = filtered.filter((task) =>
filter.projects!.includes(task.metadata.project ?? ""),
);
}
if (filter.contexts && filter.contexts.length > 0) {
filtered = filtered.filter((task) =>
filter.contexts!.includes(task.metadata.context ?? ""),
);
}
if (filter.query && filter.query.trim()) {
const q = filter.query.toLowerCase();
filtered = filtered.filter((task) =>
task.content.toLowerCase().includes(q),
);
}
this.filteredTasks = filtered;
}
private renderBoard(): void {
if (!this.boardContainerEl) return;
this.destroySortables();
this.boardContainerEl.empty();
const layoutMode = this.getLayoutMode();
const isVertical = layoutMode === "compact" || layoutMode === "narrow";
const boardEl = this.boardContainerEl.createDiv({
cls: `tg-kanban-board ${isVertical ? "is-vertical" : "is-horizontal"}`,
});
for (const column of DEFAULT_COLUMNS) {
this.renderColumn(boardEl, column);
}
}
private renderColumn(board: HTMLElement, column: KanbanColumn): void {
const columnTasks = this.getTasksForColumn(column);
const columnEl = board.createDiv({
cls: "tg-kanban-column",
attr: { "data-column-id": column.id },
});
const headerEl = columnEl.createDiv({ cls: "tg-kanban-column-header" });
headerEl.createSpan({
cls: "tg-kanban-column-title",
text: column.label,
});
headerEl.createSpan({
cls: "tg-kanban-column-count",
text: `${columnTasks.length}`,
});
const listEl = columnEl.createDiv({
cls: "tg-kanban-column-list",
attr: { "data-column-id": column.id },
});
for (const task of columnTasks) {
this.renderCard(listEl, task);
}
const sortable = new Sortable(listEl, {
group: "kanban-tasks",
animation: 150,
ghostClass: "tg-kanban-card-ghost",
chosenClass: "tg-kanban-card-chosen",
dragClass: "tg-kanban-card-drag",
onEnd: (evt) => {
this.handleDragEnd(evt);
},
});
this.sortableInstances.push(sortable);
}
private getTasksForColumn(column: KanbanColumn): Task[] {
return this.filteredTasks.filter((task) => {
if (column.id === "done") {
return task.completed;
}
return column.statusChars.includes(task.status);
});
}
private renderCard(container: HTMLElement, task: Task): void {
const cardEl = container.createDiv({
cls: "tg-kanban-card",
attr: { "data-task-id": task.id },
});
const contentEl = cardEl.createDiv({ cls: "tg-kanban-card-content" });
contentEl.createDiv({
cls: "tg-kanban-card-title",
text: task.content,
});
const metaEl = cardEl.createDiv({ cls: "tg-kanban-card-meta" });
if (task.metadata.project) {
metaEl.createSpan({
cls: "tg-kanban-card-project",
text: task.metadata.project,
});
}
if (task.metadata.dueDate) {
const dueDate = new Date(task.metadata.dueDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
const isOverdue = dueDate < now && !task.completed;
metaEl.createSpan({
cls: `tg-kanban-card-due ${isOverdue ? "is-overdue" : ""}`,
text: this.formatDate(dueDate),
});
}
if (task.metadata.tags && task.metadata.tags.length > 0) {
const tagsEl = cardEl.createDiv({ cls: "tg-kanban-card-tags" });
for (const tag of task.metadata.tags.slice(0, 3)) {
tagsEl.createSpan({
cls: "tg-kanban-card-tag",
text: tag.replace(/^#/, ""),
});
}
if (task.metadata.tags.length > 3) {
tagsEl.createSpan({
cls: "tg-kanban-card-tag tg-kanban-card-tag-more",
text: `+${task.metadata.tags.length - 3}`,
});
}
}
this.registerDomEvent(contentEl, "click", async () => {
await this.openTask(task);
});
}
private formatDate(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const diff = Math.floor(
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
if (diff < -1) return `${Math.abs(diff)}d ago`;
if (diff < 7) return `In ${diff}d`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
private async handleDragEnd(evt: Sortable.SortableEvent): Promise<void> {
const taskId = evt.item.dataset.taskId;
const targetColumnId = (evt.to as HTMLElement).dataset.columnId;
if (!taskId || !targetColumnId) return;
const task = this.tasks.find((t) => t.id === taskId);
if (!task) return;
const targetColumn = DEFAULT_COLUMNS.find(
(c) => c.id === targetColumnId,
);
if (!targetColumn) return;
const newCompleted = targetColumn.id === "done";
const newStatus = targetColumn.statusChars[0];
if (task.completed === newCompleted && task.status === newStatus) {
return;
}
await this.updateTaskStatus(task, newStatus, newCompleted);
}
private async updateTaskStatus(
task: Task,
newStatus: string,
completed: boolean,
): Promise<void> {
if (!this.plugin.writeAPI) {
new Notice("Write API not available");
return;
}
const result = await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
completed,
status: newStatus,
});
if (!result.success) {
new Notice(result.error ?? "Failed to update task");
void this.refreshTasks();
}
}
private async openTask(task: Task): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) {
return;
}
await this.app.workspace.getLeaf("tab").openFile(file);
}
private destroySortables(): void {
for (const sortable of this.sortableInstances) {
sortable.destroy();
}
this.sortableInstances = [];
}
}

View file

@ -0,0 +1,251 @@
import { Notice, TFile, debounce } from "obsidian";
import type { WorkspaceLeaf } from "obsidian";
import type { Task } from "@/types/task";
import TaskProgressBarPlugin from "@/index";
import { BaseWidgetView } from "../core/BaseWidgetView";
import { Events, on } from "@/dataflow/events/Events";
import { isDataflowEnabled } from "@/dataflow/createDataflow";
import {
globalFilterContext,
type GlobalFilterState,
} from "../core/GlobalFilterContext";
export const PROJECTS_WIDGET_VIEW_TYPE = "task-genius-widget-projects";
interface ProjectStats {
name: string;
total: number;
completed: number;
overdue: number;
tasks: Task[];
}
interface ProjectsWidgetState {
linked: boolean;
selectedProject: string | null;
[key: string]: unknown;
}
const DEFAULT_STATE: ProjectsWidgetState = {
linked: true,
selectedProject: null,
};
export class ProjectsWidgetView extends BaseWidgetView<ProjectsWidgetState> {
private projectContainerEl?: HTMLElement;
private tasks: Task[] = [];
private projectStats: ProjectStats[] = [];
private refreshScheduled = debounce(() => {
void this.refreshTasks();
}, 500);
constructor(leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) {
super(leaf, plugin, { ...DEFAULT_STATE });
}
getViewType(): string {
return PROJECTS_WIDGET_VIEW_TYPE;
}
getDisplayText(): string {
return "Projects Widget";
}
protected isLinkable(): boolean {
return true;
}
override async onOpen(): Promise<void> {
await super.onOpen();
if (
isDataflowEnabled(this.plugin) &&
this.plugin.dataflowOrchestrator
) {
this.registerEvent(
on(this.app, Events.TASK_CACHE_UPDATED, () => {
this.refreshScheduled();
}),
);
}
}
protected onGlobalFilterChanged(_state: GlobalFilterState): void {
this.renderProjects();
}
protected async render(): Promise<void> {
const bodyEl = this.getBodyEl();
if (!this.projectContainerEl) {
bodyEl.empty();
this.projectContainerEl = bodyEl.createDiv({
cls: "tg-projects-container",
});
}
await this.refreshTasks();
}
private async refreshTasks(): Promise<void> {
const queryAPI = this.plugin.dataflowOrchestrator?.getQueryAPI();
if (!queryAPI) {
this.projectContainerEl?.empty();
this.projectContainerEl?.createDiv({
cls: "tg-widget-empty",
text: "Dataflow is not available",
});
return;
}
this.tasks = await queryAPI.getAllTasks();
this.projectStats = this.aggregateProjects(this.tasks);
this.renderProjects();
}
private aggregateProjects(tasks: Task[]): ProjectStats[] {
const projectMap = new Map<string, ProjectStats>();
const now = new Date();
now.setHours(0, 0, 0, 0);
for (const task of tasks) {
const projectName = task.metadata.project ?? "No Project";
if (!projectMap.has(projectName)) {
projectMap.set(projectName, {
name: projectName,
total: 0,
completed: 0,
overdue: 0,
tasks: [],
});
}
const stats = projectMap.get(projectName)!;
stats.total++;
stats.tasks.push(task);
if (task.completed) {
stats.completed++;
} else if (task.metadata.dueDate) {
const dueDate = new Date(task.metadata.dueDate);
dueDate.setHours(0, 0, 0, 0);
if (dueDate < now) {
stats.overdue++;
}
}
}
return Array.from(projectMap.values()).sort((a, b) => {
if (a.name === "No Project") return 1;
if (b.name === "No Project") return -1;
const aProgress = a.total > 0 ? a.completed / a.total : 0;
const bProgress = b.total > 0 ? b.completed / b.total : 0;
if (aProgress !== bProgress) {
return aProgress - bProgress;
}
return b.total - a.total;
});
}
private renderProjects(): void {
if (!this.projectContainerEl) return;
this.projectContainerEl.empty();
const layoutMode = this.getLayoutMode();
if (this.projectStats.length === 0) {
this.projectContainerEl.createDiv({
cls: "tg-widget-empty",
text: "No projects",
});
return;
}
const gridEl = this.projectContainerEl.createDiv({
cls: `tg-projects-grid tg-projects-grid-${layoutMode}`,
});
for (const project of this.projectStats) {
this.renderProjectCard(gridEl, project);
}
}
private renderProjectCard(
container: HTMLElement,
project: ProjectStats,
): void {
const isSelected = this.state.selectedProject === project.name;
const progress =
project.total > 0
? Math.round((project.completed / project.total) * 100)
: 0;
const cardEl = container.createDiv({
cls: `tg-projects-card ${isSelected ? "is-selected" : ""}`,
});
const headerEl = cardEl.createDiv({ cls: "tg-projects-card-header" });
headerEl.createDiv({
cls: "tg-projects-card-name",
text: project.name,
});
if (project.overdue > 0) {
headerEl.createSpan({
cls: "tg-projects-card-overdue",
text: `${project.overdue} overdue`,
});
}
const progressContainerEl = cardEl.createDiv({
cls: "tg-projects-card-progress",
});
const progressTrackEl = progressContainerEl.createDiv({
cls: "tg-projects-progress-track",
});
const progressFillEl = progressTrackEl.createDiv({
cls: "tg-projects-progress-fill",
});
progressFillEl.style.width = `${progress}%`;
if (progress === 100) {
progressFillEl.addClass("is-complete");
} else if (project.overdue > 0) {
progressFillEl.addClass("has-overdue");
}
const statsEl = cardEl.createDiv({ cls: "tg-projects-card-stats" });
statsEl.createSpan({
cls: "tg-projects-card-stat",
text: `${project.completed}/${project.total} tasks`,
});
statsEl.createSpan({
cls: "tg-projects-card-percent",
text: `${progress}%`,
});
this.registerDomEvent(cardEl, "click", () => {
this.selectProject(project.name);
});
}
private selectProject(projectName: string): void {
const newSelection =
this.state.selectedProject === projectName ? null : projectName;
this.updateWidgetState({ selectedProject: newSelection });
if (this.state.linked && newSelection) {
globalFilterContext.setState({ projects: [newSelection] });
} else if (this.state.linked) {
globalFilterContext.setState({ projects: [] });
}
}
}

View file

@ -0,0 +1,398 @@
import { Notice, TFile, debounce, setIcon } from "obsidian";
import type { WorkspaceLeaf } from "obsidian";
import type { Task } from "@/types/task";
import type { GroupByDimension } from "@/types/groupBy";
import TaskProgressBarPlugin from "@/index";
import { BaseWidgetView } from "../core/BaseWidgetView";
import { Events, on } from "@/dataflow/events/Events";
import { isDataflowEnabled } from "@/dataflow/createDataflow";
import {
globalFilterContext,
type GlobalFilterState,
} from "../core/GlobalFilterContext";
import {
groupTasksBy,
getGroupByDimensionLabel,
} from "@/utils/grouping/taskGrouping";
export const TASKS_WIDGET_VIEW_TYPE = "task-genius-widget-tasks";
interface TasksWidgetState {
linked: boolean;
groupBy: GroupByDimension;
collapsedGroups: string[];
[key: string]: unknown;
}
const DEFAULT_STATE: TasksWidgetState = {
linked: true,
groupBy: "tags",
collapsedGroups: [],
};
export class TasksWidgetView extends BaseWidgetView<TasksWidgetState> {
private taskContainerEl?: HTMLElement;
private tasks: Task[] = [];
private filteredTasks: Task[] = [];
private refreshScheduled = debounce(() => {
void this.refreshTasks();
}, 500);
constructor(leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) {
super(leaf, plugin, { ...DEFAULT_STATE });
}
getViewType(): string {
return TASKS_WIDGET_VIEW_TYPE;
}
getDisplayText(): string {
return "Tasks Widget";
}
protected isLinkable(): boolean {
return true;
}
override async onOpen(): Promise<void> {
await super.onOpen();
if (
isDataflowEnabled(this.plugin) &&
this.plugin.dataflowOrchestrator
) {
this.registerEvent(
on(this.app, Events.TASK_CACHE_UPDATED, () => {
this.refreshScheduled();
}),
);
}
}
protected onGlobalFilterChanged(state: GlobalFilterState): void {
this.applyFilter(state);
this.renderTaskList();
}
protected async render(): Promise<void> {
const bodyEl = this.getBodyEl();
if (!this.taskContainerEl) {
bodyEl.empty();
this.renderToolbar(bodyEl);
this.taskContainerEl = bodyEl.createDiv({
cls: "tg-tasks-container",
});
}
await this.refreshTasks();
}
private renderToolbar(parentEl: HTMLElement): void {
const toolbar = parentEl.createDiv({ cls: "tg-tasks-toolbar" });
toolbar.createSpan({
cls: "tg-tasks-toolbar-label",
text: "Group by:",
});
const select = toolbar.createEl("select", {
cls: "tg-tasks-group-select dropdown",
});
const dimensions: GroupByDimension[] = [
"none",
"dueDate",
"priority",
"project",
"tags",
"status",
"filePath",
];
for (const dim of dimensions) {
const optionEl = select.createEl("option", {
value: dim,
text: getGroupByDimensionLabel(dim),
});
if (dim === this.state.groupBy) {
optionEl.selected = true;
}
}
this.registerDomEvent(select, "change", () => {
this.updateWidgetState({
groupBy: select.value as GroupByDimension,
collapsedGroups: [],
});
});
}
private async refreshTasks(): Promise<void> {
const queryAPI = this.plugin.dataflowOrchestrator?.getQueryAPI();
if (!queryAPI) {
this.taskContainerEl?.empty();
this.taskContainerEl?.createDiv({
cls: "tg-widget-empty",
text: "Dataflow is not available",
});
return;
}
this.tasks = await queryAPI.getAllTasks();
this.tasks = this.tasks.filter((t) => !t.completed);
if (this.state.linked) {
this.applyFilter(globalFilterContext.getState());
} else {
this.filteredTasks = [...this.tasks];
}
this.renderTaskList();
}
private applyFilter(filter: GlobalFilterState): void {
let filtered = [...this.tasks];
if (filter.tags && filter.tags.length > 0) {
filtered = filtered.filter((task) =>
filter.tags!.some((tag) => task.metadata.tags?.includes(tag)),
);
}
if (filter.projects && filter.projects.length > 0) {
filtered = filtered.filter((task) =>
filter.projects!.includes(task.metadata.project ?? ""),
);
}
if (filter.contexts && filter.contexts.length > 0) {
filtered = filtered.filter((task) =>
filter.contexts!.includes(task.metadata.context ?? ""),
);
}
if (filter.query && filter.query.trim()) {
const q = filter.query.toLowerCase();
filtered = filtered.filter((task) =>
task.content.toLowerCase().includes(q),
);
}
this.filteredTasks = filtered;
}
private renderTaskList(): void {
if (!this.taskContainerEl) return;
this.taskContainerEl.empty();
if (this.filteredTasks.length === 0) {
this.taskContainerEl.createDiv({
cls: "tg-widget-empty",
text: "No tasks",
});
return;
}
const groups = groupTasksBy(
this.filteredTasks,
this.state.groupBy,
this.plugin.settings,
);
for (const group of groups) {
this.renderGroup(
this.taskContainerEl,
group.title,
group.tasks,
group.key,
);
}
}
private renderGroup(
container: HTMLElement,
title: string,
tasks: Task[],
key: string,
): void {
const isCollapsed = this.state.collapsedGroups.includes(key);
const groupEl = container.createDiv({ cls: "tg-tasks-group" });
const headerEl = groupEl.createDiv({ cls: "tg-tasks-group-header" });
const chevronEl = headerEl.createSpan({
cls: "tg-tasks-group-chevron",
});
setIcon(chevronEl, isCollapsed ? "chevron-right" : "chevron-down");
headerEl.createSpan({
cls: "tg-tasks-group-title",
text: title,
});
headerEl.createSpan({
cls: "tg-tasks-group-count",
text: `${tasks.length}`,
});
this.registerDomEvent(headerEl, "click", () => {
this.toggleGroupCollapse(key);
});
if (!isCollapsed) {
const listEl = groupEl.createDiv({ cls: "tg-tasks-group-list" });
const sortedTasks = this.sortTasksInGroup(tasks);
for (const task of sortedTasks) {
this.renderTaskItem(listEl, task);
}
}
}
private sortTasksInGroup(tasks: Task[]): Task[] {
return [...tasks].sort((a, b) => {
// Primary: due date ascending (tasks with due dates first)
const dueDateA = a.metadata.dueDate
? new Date(a.metadata.dueDate).getTime()
: Infinity;
const dueDateB = b.metadata.dueDate
? new Date(b.metadata.dueDate).getTime()
: Infinity;
if (dueDateA !== dueDateB) {
return dueDateA - dueDateB;
}
// Secondary: priority descending (higher priority first)
const priorityA = a.metadata.priority ?? 0;
const priorityB = b.metadata.priority ?? 0;
if (priorityA !== priorityB) {
return priorityB - priorityA;
}
// Tertiary: content alphabetically
return a.content.localeCompare(b.content);
});
}
private toggleGroupCollapse(key: string): void {
const collapsed = [...this.state.collapsedGroups];
const index = collapsed.indexOf(key);
if (index >= 0) {
collapsed.splice(index, 1);
} else {
collapsed.push(key);
}
this.updateWidgetState({ collapsedGroups: collapsed });
}
private renderTaskItem(container: HTMLElement, task: Task): void {
const itemEl = container.createDiv({ cls: "tg-tasks-item" });
const checkbox = itemEl.createEl("input", { type: "checkbox" });
checkbox.checked = Boolean(task.completed);
checkbox.addClass("task-list-item-checkbox");
this.registerDomEvent(checkbox, "change", async () => {
await this.toggleTaskCompletion(task, checkbox.checked);
});
const contentEl = itemEl.createDiv({ cls: "tg-tasks-item-content" });
const titleEl = contentEl.createDiv({
cls: "tg-tasks-item-title",
text: task.content,
});
this.registerDomEvent(titleEl, "click", async () => {
await this.openTask(task);
});
const metaEl = contentEl.createDiv({ cls: "tg-tasks-item-meta" });
if (task.metadata.dueDate) {
const dueDate = new Date(task.metadata.dueDate);
const isOverdue = dueDate < new Date();
metaEl.createSpan({
cls: `tg-tasks-item-due ${isOverdue ? "is-overdue" : ""}`,
text: this.formatDate(dueDate),
});
}
if (task.metadata.priority && task.metadata.priority > 0) {
metaEl.createSpan({
cls: `tg-tasks-item-priority priority-${task.metadata.priority}`,
text: this.getPriorityLabel(task.metadata.priority),
});
}
}
private formatDate(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const diff = Math.floor(
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
if (diff < -1) return `${Math.abs(diff)}d ago`;
if (diff < 7) return `In ${diff}d`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
private getPriorityLabel(priority: number): string {
switch (priority) {
case 5:
return "🔴";
case 4:
return "🟠";
case 3:
return "🟡";
case 2:
return "🔵";
case 1:
return "⚪";
default:
return "";
}
}
private async toggleTaskCompletion(
task: Task,
completed: boolean,
): Promise<void> {
if (!this.plugin.writeAPI) {
new Notice("Write API not available");
return;
}
const result = await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
completed,
});
if (!result.success) {
new Notice(result.error ?? "Failed to update task");
}
}
private async openTask(task: Task): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(task.filePath);
if (!(file instanceof TFile)) {
return;
}
await this.app.workspace.getLeaf("tab").openFile(file);
}
}

File diff suppressed because one or more lines are too long