docs/long-term-plans/plugin-view-adapter-api.md
Status: Planned
Date: 2026-01-20 Approach: Option B - Simpler view-adapter API (not full wrapping system) Estimated Complexity: ~500 lines of code
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:
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>
File: src/app/plugins/plugin-task-grouping.service.ts (NEW, ~150 lines)
What it does:
groupingOptions() for UI integrationapplyPluginGrouping(id, tasks) to execute groupingKey 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:
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:
registerTaskGrouping(grouping: PluginTaskGrouping): void {
this._sendMessage({
type: 'API_CALL',
method: 'registerTaskGrouping',
args: [this._pluginId, grouping],
});
}
unregisterTaskGrouping(id: string): void {
// ...
}
registerTaskGrouping: (grouping: PluginTaskGrouping) => {
this._pluginTaskGroupingService.registerGrouping(pluginId, grouping);
},
export interface PluginTaskGrouping {
/* ... */
}
export interface PluginTaskGroup {
/* ... */
}
export interface PluginGroupMetadata {
/* ... */
}
Verification:
npm run build:plugin-apiFile: src/app/features/task-view-customizer/task-view-customizer.service.ts (~50 lines modified)
Changes:
private _pluginGroupingService = inject(PluginTaskGroupingService);
public availableGroupOptions = computed(() => {
const builtIn = OPTIONS.group.list;
const pluginOptions = this._pluginGroupingService.groupingOptions();
return [...builtIn, ...pluginOptions];
});
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)
plugin to GROUP_OPTION_TYPE enumpluginId? and pluginGroupingId? to GroupOption interfaceVerification:
File: src/app/features/work-view/work-view.component.html (~10 lines modified)
Changes:
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
}
}
@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:
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:
Files to create:
docs/plugin-api-task-grouping.md (~100 lines)
packages/plugin-dev/sections-plugin-example/ (example plugin)
manifest.jsonplugin.js - Implements sections groupingREADME.md - Usage instructionsVerification:
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 ✓
// 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
},
});
PluginTaskGroupingService:
PluginGroupMetadataPipe:
E2E test: e2e/tests/plugins/task-grouping.spec.ts
Timeout protection:
Caching:
Memory:
What syncs:
persistDataSynced()) → creates operation in op-logWhat doesn't sync:
Cross-device behavior:
| 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 |
These can be added later without breaking changes:
Drag-between-groups:
onTaskMoved(taskId, fromGroup, toGroup) callbackContext menu integration:
TASK_CONTEXT_MENU_OPENLoading states:
Group statistics:
count?: numberNested groups:
PluginTaskGroup.subGroups?: PluginTaskGroup[]✅ 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
None - design is ready for implementation.
Strengths:
Potential Issues:
Side Effects: