14 KiB
Plugin System: View-Adapter API for Task Grouping
Date: 2026-01-20 Approach: Option B - Simpler view-adapter API (not full wrapping system) Estimated Complexity: ~500 lines of code
Overview
Enable plugins to provide custom task grouping (e.g., sections, kanban boards) while core handles all rendering. Plugins provide grouping logic, core provides UI rendering.
Key Benefits:
- Simple: Reuses existing TaskViewCustomizerService infrastructure
- Performant: No iframe-per-slot, just function calls
- Sync-compatible: Plugin state via existing persistDataSynced()
- Type-safe: Full TypeScript support via PluginAPI
Architecture
Plugin (JS code)
↓ registerTaskGrouping({ id, label, groupFn })
PluginTaskGroupingService (new)
↓ exposes groupingOptions signal
TaskViewCustomizerService (modified)
↓ calls plugin groupFn when selected
WorkViewComponent (minimal template changes)
↓ renders groups using existing <collapsible> + <task-list>
Implementation Plan
Task 1: Create Core Grouping Service
File: src/app/plugins/plugin-task-grouping.service.ts (NEW, ~150 lines)
What it does:
- Stores plugin grouping registrations in a signal
- Exposes
groupingOptions()for UI integration - Provides
applyPluginGrouping(id, tasks)to execute grouping - Implements caching (1 second) and timeout (5 seconds) for performance
- Cleans up when plugins are unloaded
Key types:
interface PluginTaskGrouping {
id: string;
label: string;
icon?: string;
groupFn: (tasks: Task[]) => Promise<PluginTaskGroup[]> | PluginTaskGroup[];
getGroupMetadata?: (groupKey: string) => PluginGroupMetadata;
}
interface PluginTaskGroup {
key: string;
label: string;
tasks: Task[];
icon?: string;
color?: string;
order?: number;
}
Verification:
- Unit test: Register grouping, verify it appears in groupingOptions()
- Unit test: applyPluginGrouping returns correct format
- Unit test: Timeout protection (mock slow groupFn)
- Unit test: Caching works (same tasks = cached result)
Task 2: Extend PluginAPI
Files to modify:
src/app/plugins/plugin-api.ts(~30 lines)src/app/plugins/plugin-bridge.service.ts(~20 lines)packages/plugin-api/src/types.ts(~40 lines)
Changes:
- Add method to PluginAPI class:
registerTaskGrouping(grouping: PluginTaskGrouping): void {
this._sendMessage({
type: 'API_CALL',
method: 'registerTaskGrouping',
args: [this._pluginId, grouping],
});
}
unregisterTaskGrouping(id: string): void {
// ...
}
- Add to PluginBridgeService.createBoundMethods():
registerTaskGrouping: (grouping: PluginTaskGrouping) => {
this._pluginTaskGroupingService.registerGrouping(pluginId, grouping);
},
- Export types in packages/plugin-api:
export interface PluginTaskGrouping {
/* ... */
}
export interface PluginTaskGroup {
/* ... */
}
export interface PluginGroupMetadata {
/* ... */
}
Verification:
- Build plugin-api package:
npm run build:plugin-api - TypeScript types are exported
- Integration test: Plugin can call registerTaskGrouping()
Task 3: Integrate with TaskViewCustomizerService
File: src/app/features/task-view-customizer/task-view-customizer.service.ts (~50 lines modified)
Changes:
- Inject PluginTaskGroupingService:
private _pluginGroupingService = inject(PluginTaskGroupingService);
- Expose combined grouping options:
public availableGroupOptions = computed(() => {
const builtIn = OPTIONS.group.list;
const pluginOptions = this._pluginGroupingService.groupingOptions();
return [...builtIn, ...pluginOptions];
});
- Update applyGrouping to handle plugin groupings:
private async applyGrouping(
tasks: TaskWithSubTasks[],
groupType: GROUP_OPTION_TYPE | null,
pluginGroupingId?: string,
): Promise<Record<string, TaskWithSubTasks[]>> {
if (groupType === GROUP_OPTION_TYPE.plugin && pluginGroupingId) {
return this._pluginGroupingService.applyPluginGrouping(
pluginGroupingId,
tasks,
);
}
// Existing built-in grouping logic unchanged...
}
Files to modify:
src/app/features/task-view-customizer/types.ts(~10 lines)- Add
pluginto GROUP_OPTION_TYPE enum - Add
pluginId?andpluginGroupingId?to GroupOption interface
- Add
Verification:
- Start app with test plugin
- Plugin grouping appears in customizer dropdown
- Selecting plugin grouping applies grouping correctly
- Console shows no errors
Task 4: Update Work View Template
File: src/app/features/work-view/work-view.component.html (~10 lines modified)
Changes:
- Create metadata pipe (NEW file:
src/app/ui/pipes/plugin-group-metadata.pipe.ts, ~40 lines):
@Pipe({ name: 'pluginGroupMetadata', standalone: true })
export class PluginGroupMetadataPipe implements PipeTransform {
transform(groupKey: string): { label: string; icon?: string } {
// Gets metadata from plugin or falls back to groupKey
}
}
- Update template to use metadata:
@for (group of customized.grouped | keyvalue; track group.key) { @let metadata = group.key
| pluginGroupMetadata;
<collapsible
[title]="metadata.label"
[icon]="metadata.icon"
[isIconBefore]="true"
[isExpanded]="true"
>
<task-list
[tasks]="group.value"
listId="PARENT"
listModelId="UNDONE"
></task-list>
</collapsible>
}
Verification:
- Plugin-provided group labels render correctly
- Icons appear if provided by plugin
- Built-in groups still work (backward compatibility)
Task 5: Plugin Cleanup Integration
File: src/app/plugins/plugin-cleanup.service.ts (~10 lines)
Changes:
Add cleanup of groupings when plugin is unloaded:
unload(pluginId: string): void {
// ... existing cleanup ...
this._pluginTaskGroupingService.cleanupPlugin(pluginId);
}
Verification:
- Disable plugin in settings
- Plugin grouping option disappears from UI
- No memory leaks (check with Chrome DevTools)
Task 6: Documentation & Example Plugin
Files to create:
-
docs/plugin-api-task-grouping.md(~100 lines)- API reference for registerTaskGrouping
- Type definitions
- Best practices (performance, state management)
- Complete sections plugin example
-
packages/plugin-dev/sections-plugin-example/(example plugin)manifest.jsonplugin.js- Implements sections groupingREADME.md- Usage instructions- Demonstrates:
- Registering grouping
- Persisting section assignments via persistDataSynced()
- Using ANY_TASK_UPDATE hook to refresh grouping
Verification:
- Build example plugin
- Install in app
- Create sections, assign tasks
- Verify sections sync across browser tabs (persistDataSynced)
- Verify sections work after app reload
Critical Files Summary
New files (~290 lines):
src/app/plugins/plugin-task-grouping.service.ts(~150 lines)src/app/ui/pipes/plugin-group-metadata.pipe.ts(~40 lines)docs/plugin-api-task-grouping.md(~100 lines)
Modified files (~200 lines changes):
src/app/plugins/plugin-api.ts(~30 lines)src/app/plugins/plugin-bridge.service.ts(~20 lines)src/app/features/task-view-customizer/task-view-customizer.service.ts(~50 lines)src/app/features/task-view-customizer/types.ts(~10 lines)src/app/features/work-view/work-view.component.html(~10 lines)src/app/plugins/plugin-cleanup.service.ts(~10 lines)packages/plugin-api/src/types.ts(~40 lines)
Total: ~490 lines ✓
Example: Sections Plugin
// sections-plugin.js
let taskSections = {}; // { taskId: sectionName }
// Load persisted section assignments
plugin.loadPersistedData().then((data) => {
taskSections = data ? JSON.parse(data) : {};
});
// Register grouping
plugin.registerTaskGrouping({
id: 'sections',
label: 'By Section',
icon: 'category',
groupFn: async (tasks) => {
const groups = new Map();
const sectionOrder = ['Urgent', 'Today', 'This Week', 'Backlog'];
for (const task of tasks) {
const section = taskSections[task.id] || 'Uncategorized';
if (!groups.has(section)) {
groups.set(section, []);
}
groups.get(section).push(task);
}
return Array.from(groups.entries()).map(([key, tasks]) => ({
key,
label: key,
tasks,
icon: key === 'Urgent' ? 'priority_high' : 'folder',
order: sectionOrder.indexOf(key),
}));
},
getGroupMetadata: (groupKey) => ({
label: groupKey,
icon: groupKey === 'Urgent' ? 'priority_high' : 'folder',
}),
});
// Helper: Assign task to section
async function setTaskSection(taskId, sectionName) {
taskSections[taskId] = sectionName;
await plugin.persistDataSynced(JSON.stringify(taskSections));
}
// Provide UI to move tasks (via header button)
plugin.registerHeaderButton({
label: 'Manage Sections',
icon: 'category',
onClick: () => {
plugin.showIndexHtmlAsView(); // Show section management UI
},
});
Testing Strategy
Unit Tests
PluginTaskGroupingService:
- Registration adds grouping to signal
- applyPluginGrouping executes groupFn correctly
- Timeout protection (5s limit)
- Caching works (same task IDs = cached result)
- Cleanup removes all groupings for plugin
PluginGroupMetadataPipe:
- Returns metadata for plugin groups
- Falls back for built-in groups
- Handles missing metadata gracefully
Integration Tests
E2E test: e2e/tests/plugins/task-grouping.spec.ts
- Load test plugin with grouping
- Select plugin grouping in customizer
- Verify tasks are grouped correctly in UI
- Verify groups have correct labels/icons
- Disable plugin → grouping option disappears
Manual Testing Checklist
- Plugin grouping appears in customizer dropdown
- Selecting grouping shows tasks in groups
- Group labels and icons render correctly
- Collapsible groups work (expand/collapse)
- Drag-drop resets grouping (existing behavior)
- Plugin data syncs across browser tabs
- Groups persist after page reload (via persistDataSynced)
- Disabling plugin removes grouping option
- No console errors or warnings
- Performance: 100+ tasks group in < 1 second
Performance Considerations
Timeout protection:
- groupFn execution limited to 5 seconds
- Prevents slow plugins from freezing UI
- Falls back to "All Tasks" group on timeout
Caching:
- Results cached for 1 second
- Cache invalidated when task list changes (compare task IDs)
- Prevents re-running expensive grouping on every render
Memory:
- Cache cleared after 1 second
- Max ~10KB per grouping
- Cleanup on plugin unload
Sync & Operation Log
What syncs:
- Plugin state (via
persistDataSynced()) → creates operation in op-log - Syncs across all devices running the same plugin
What doesn't sync:
- Grouping function code (plugin installed locally)
- Selected grouping option (local UI preference)
- Collapsed/expanded state (local UI state)
Cross-device behavior:
- Device A: Plugin installed, assigns tasks to sections
- Device B (with plugin): Loads synced section data, grouping works
- Device B (no plugin): Data syncs but has no effect, tasks visible in default view
Risks & Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Slow groupFn blocks UI | Medium | 5s timeout, caching, performance guidelines in docs |
| Plugin state corruption | Low | try/catch + fallback to "All Tasks" group |
| Drag-drop UX unclear | Low | Use existing behavior (reset grouping), document limitation |
| Plugin not installed on other device | Low | Tasks still accessible, just not grouped |
Future Enhancements (Not in MVP)
These can be added later without breaking changes:
-
Drag-between-groups:
- Add optional
onTaskMoved(taskId, fromGroup, toGroup)callback - Plugin updates state when task dragged to different group
- Add optional
-
Context menu integration:
- New hook:
TASK_CONTEXT_MENU_OPEN - Plugins can add "Move to Section" menu items
- New hook:
-
Loading states:
- Show spinner while groupFn executes
- Better UX for slow grouping functions
-
Group statistics:
- Show task count per group in header
- Optional metadata field:
count?: number
-
Nested groups:
- Support hierarchical grouping (e.g., Project → Section → Priority)
PluginTaskGroup.subGroups?: PluginTaskGroup[]
Success Criteria
✅ Plugins can register custom grouping via registerTaskGrouping()
✅ Plugin groupings appear in customizer UI
✅ Core renders groups using existing components
✅ Plugin state syncs via persistDataSynced()
✅ Performance: < 5% overhead with plugin grouping
✅ ~500 lines of implementation code
✅ Type-safe plugin development
✅ Backward compatible (no breaking changes)
✅ Example sections plugin works end-to-end
✅ All tests passing
Open Questions
None - design is ready for implementation.
Confidence: 90%
Strengths:
- Reuses existing architecture (TaskViewCustomizerService, signals, persistence)
- Simple implementation (~500 lines)
- No performance concerns (timeout + caching)
- Clean plugin API
Potential Issues:
- Drag-drop UX limitation (resets grouping) - but acceptable for MVP
- Plugin must handle async state loading - documented in example
Side Effects:
- Minimal: just extends existing customizer infrastructure
- No breaking changes to core or existing plugins