mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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:
parent
862e22f8d4
commit
bb3cc33c08
22 changed files with 5228 additions and 12 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -53,3 +53,4 @@ packages/*/node_modules
|
|||
|
||||
# Documentation site (separate repository)
|
||||
docs-site
|
||||
PLAN.md
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
27
src/index.ts
27
src/index.ts
|
|
@ -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
906
src/styles/widgets.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
368
src/widgets/codeblock/WidgetCodeBlockProcessor.ts
Normal file
368
src/widgets/codeblock/WidgetCodeBlockProcessor.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
192
src/widgets/core/BaseWidgetView.ts
Normal file
192
src/widgets/core/BaseWidgetView.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/widgets/core/GlobalFilterContext.ts
Normal file
35
src/widgets/core/GlobalFilterContext.ts
Normal 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();
|
||||
|
||||
26
src/widgets/core/WidgetFactory.ts
Normal file
26
src/widgets/core/WidgetFactory.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
81
src/widgets/core/WidgetShell.ts
Normal file
81
src/widgets/core/WidgetShell.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
13
src/widgets/core/useWidgetState.ts
Normal file
13
src/widgets/core/useWidgetState.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
100
src/widgets/registerWidgets.ts
Normal file
100
src/widgets/registerWidgets.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
190
src/widgets/renderers/BaseWidgetRenderer.ts
Normal file
190
src/widgets/renderers/BaseWidgetRenderer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
72
src/widgets/renderers/HabitWidgetRenderer.ts
Normal file
72
src/widgets/renderers/HabitWidgetRenderer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
361
src/widgets/renderers/KanbanWidgetRenderer.ts
Normal file
361
src/widgets/renderers/KanbanWidgetRenderer.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
611
src/widgets/renderers/TasksWidgetRenderer.ts
Normal file
611
src/widgets/renderers/TasksWidgetRenderer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
src/widgets/renderers/index.ts
Normal file
26
src/widgets/renderers/index.ts
Normal 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";
|
||||
256
src/widgets/views/CalendarWidgetView.ts
Normal file
256
src/widgets/views/CalendarWidgetView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
180
src/widgets/views/ForecastWidgetView.ts
Normal file
180
src/widgets/views/ForecastWidgetView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
361
src/widgets/views/KanbanWidgetView.ts
Normal file
361
src/widgets/views/KanbanWidgetView.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
251
src/widgets/views/ProjectsWidgetView.ts
Normal file
251
src/widgets/views/ProjectsWidgetView.ts
Normal 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: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
398
src/widgets/views/TasksWidgetView.ts
Normal file
398
src/widgets/views/TasksWidgetView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
718
styles.css
718
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue