Skip to content

feat: plugin framework hardening — dynamic UI, persistent state, enriched metadata#106

Merged
fatherlinux merged 10 commits into
mainfrom
feature/101-plugin-hardening
Jun 24, 2026
Merged

feat: plugin framework hardening — dynamic UI, persistent state, enriched metadata#106
fatherlinux merged 10 commits into
mainfrom
feature/101-plugin-hardening

Conversation

@fatherlinux

Copy link
Copy Markdown
Member

Summary

  • Plugin METADATA now declares capability flags: has_tab, has_timer_types, has_counts, has_sync, has_import_export, has_history_decorators
  • Plugin toggle state persists across container restarts via IndexedDB
  • Extension tab visibility is metadata-driven (no more hardcoded 'todos' checks)
  • Plugin cards in Settings show dynamic count badges and import/export buttons
  • Type dropdown queries active plugins with has_timer_types via a generic provider loop
  • Edit modal correctly shows linked pomodoro types (e.g. "To-do") that aren't in the standard type list
  • All views (History, weekly overview, Type dropdown) refresh when plugins are toggled

Closes #101

Test plan

  • Toggle Todos off → To-do tab hides, Type dropdown drops todo items, History drops annotations
  • Toggle Todos on → everything repopulates immediately
  • Edit a todo-linked pomodoro → Type shows "To-do" (not blank)
  • Plugin cards show count badge and import/export buttons
  • 133 existing tests pass

🤖 Generated with Claude Code

fatherlinux and others added 10 commits June 23, 2026 17:28
Six user stories covering plugin-aware type dropdown, persistent plugin
state, enriched metadata with capability flags, dynamic settings cards,
frontend PluginUI registry, and plugin sync contracts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PLUGIN_METADATA now declares has_tab, tab_label, tab_id,
has_timer_types, has_counts, has_sync, has_import_export, and
has_history_decorators. These flags drive dynamic UI integration
instead of hardcoded plugin ID checks in core code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
togglePlugin() now saves plugin_state_<id> to IndexedDB after toggling.
On boot, Phase 2b reads saved states and re-applies them to the server
plugin registry, then refreshes extension tab visibility.

Also adds Storage.saveSetting(key, value) convenience method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
updateExtensionTabs() now loops over all extension plugins with
has_tab: true instead of hardcoding a check for 'todos'. Tab/view/card
IDs follow the convention tab-{tab_id}, {tab_id}-view, {tab_id}-settings-card.

Renamed todo-settings-card to todos-settings-card for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plugin cards in Settings now show:
- Count badges for plugins with has_counts (e.g. "12 items")
- Import/export buttons for plugins with has_import_export
- Routed through exportPluginCSV/importPluginCSV dispatchers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
populateTypeDropdowns() now queries /api/plugins for active plugins with
has_timer_types and builds a _timerTypeProviders array. renderTypeDropdownTopLevel()
and onTypeDropdownChange() loop over providers instead of checking hardcoded
_typeDropdownTodosActive/_typeDropdownTodoLists globals.

Future plugins (RT tickets, checklists) add themselves as providers — no
changes to core dropdown code needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
populateTypeDropdowns() was only called on settings load, not when
toggling plugins. Disabling the Todos plugin left stale todo items
in the Type dropdown until page refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pomodoros created via todo linking have type "To-do" which isn't in the
standard pomodoro_types list. The edit modal dropdown showed blank because
the value had no matching option. Now prepends the type as an option if
it's missing from the dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
History and weekly overview now reload alongside the Type dropdown when
a plugin is enabled/disabled. Clears stale todoMap in History and
ensures the weekly grid reflects plugin state changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Edit modal now stashes the original linked_todo_id on the type select's
data attribute when opening. On save, preserves it unless the user
explicitly selects a different todo. Prevents data loss on edit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the "Plugin Framework Hardening" specification, introducing persistent plugin states via IndexedDB, dynamic rendering of plugin settings cards, and capability-driven extension tabs. However, the current implementation still contains several hardcoded references to the 'todos' plugin in the frontend. The review feedback focuses on decoupling this logic by introducing a generic frontend PluginUI registry to dynamically manage counts, timer type providers, and CSV import/export handlers, fully aligning the code with the architectural goals of the specification.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread templates/index.html
let countBadge = '';
if (isActive && plugin.has_counts) {
try {
const count = plugin.id === 'todos' ? (await Storage.getTodos()).length : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To adhere to the plugin framework design goals (SC-004 and User Story 5), avoid hardcoding plugin-specific logic like plugin.id === 'todos'. Instead, query the count dynamically from the PluginUI registry.

Suggested change
const count = plugin.id === 'todos' ? (await Storage.getTodos()).length : 0;
const count = await PluginUI.getCount(plugin.id);

Comment thread templates/index.html
Comment on lines +4080 to 4086
for (const plugin of timerTypePlugins) {
if (plugin.id === 'todos') {
const lists = await Storage.getTodoLists();
const todos = (await Storage.getTodos()).filter(t => t.status === 'pending');
_timerTypeProviders.push({ plugin, lists, todos });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of hardcoding the 'todos' plugin check, dynamically retrieve the timer type provider from the PluginUI registry. This allows any active plugin with has_timer_types to seamlessly inject its items.

Suggested change
for (const plugin of timerTypePlugins) {
if (plugin.id === 'todos') {
const lists = await Storage.getTodoLists();
const todos = (await Storage.getTodos()).filter(t => t.status === 'pending');
_timerTypeProviders.push({ plugin, lists, todos });
}
}
for (const plugin of timerTypePlugins) {
const providerFn = PluginUI.getTimerTypeProvider(plugin.id);
if (providerFn) {
const data = await providerFn();
_timerTypeProviders.push({ plugin, ...data });
}
}

Comment thread templates/index.html
Comment on lines +4107 to 4116
for (const provider of _timerTypeProviders) {
if (provider.plugin.id === 'todos' && provider.lists.length > 0) {
html += '<option disabled>──────────</option>';
for (const list of provider.lists) {
const count = provider.todos.filter(t => t.list_id === list.id).length;
if (count === 0) continue;
html += `<option value="list:${list.id}">📋 ${escapeHtml(list.name)} (${count})</option>`;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the hardcoded check for 'todos' here as well. Checking for the presence of provider.lists makes the rendering logic generic and reusable for other list-based plugins.

Suggested change
for (const provider of _timerTypeProviders) {
if (provider.plugin.id === 'todos' && provider.lists.length > 0) {
html += '<option disabled>──────────</option>';
for (const list of provider.lists) {
const count = provider.todos.filter(t => t.list_id === list.id).length;
if (count === 0) continue;
html += `<option value="list:${list.id}">📋 ${escapeHtml(list.name)} (${count})</option>`;
}
}
}
for (const provider of _timerTypeProviders) {
if (provider.lists && provider.lists.length > 0) {
html += '<option disabled>──────────</option>';
for (const list of provider.lists) {
const count = provider.todos.filter(t => t.list_id === list.id).length;
if (count === 0) continue;
html += `<option value="list:${list.id}">📋 ${escapeHtml(list.name)} (${count})</option>`;
}
}
}

Comment thread templates/index.html
Comment on lines +4129 to 4133
const todosProvider = _timerTypeProviders.find(p => p.plugin.id === 'todos');
if (!todosProvider) return;
const listTodos = todosProvider.todos.filter(t => t.list_id === listId);
const list = todosProvider.lists.find(l => l.id === listId);
const label = list ? list.name : 'To-do';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of searching specifically for the 'todos' provider, find the provider that contains the selected list ID. This decouples the cascading dropdown logic from any specific plugin ID.

Suggested change
const todosProvider = _timerTypeProviders.find(p => p.plugin.id === 'todos');
if (!todosProvider) return;
const listTodos = todosProvider.todos.filter(t => t.list_id === listId);
const list = todosProvider.lists.find(l => l.id === listId);
const label = list ? list.name : 'To-do';
const provider = _timerTypeProviders.find(p => p.lists && p.lists.some(l => l.id === listId));
if (!provider) return;
const listTodos = provider.todos.filter(t => t.list_id === listId);
const list = provider.lists.find(l => l.id === listId);

Comment thread templates/index.html
Comment on lines +5808 to +5813
function exportPluginCSV(pluginId) {
if (pluginId === 'todos') return exportTodoCSV();
}
function importPluginCSV(pluginId, input) {
if (pluginId === 'todos') return importTodoCSV(input);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To fully implement the frontend PluginUI registry specified in User Story 5 and FR-008, define a generic PluginUI registry here and register the built-in todos plugin contributions. This completely removes the hardcoded checks and makes the framework extensible for future plugins.

        // ── PluginUI Registry ──────────────────────────────────────
        const PluginUI = {
            _countProviders: {},
            _timerTypeProviders: {},
            _csvHandlers: {},
            registerCountProvider: function(id, fn) { this._countProviders[id] = fn; },
            registerTimerTypeProvider: function(id, fn) { this._timerTypeProviders[id] = fn; },
            registerCSVHandlers: function(id, expFn, impFn) { this._csvHandlers[id] = { export: expFn, import: impFn }; },
            getCount: async function(id) { return this._countProviders[id] ? await this._countProviders[id]() : 0; },
            getTimerTypeProvider: function(id) { return this._timerTypeProviders[id]; },
            exportCSV: function(id) { if (this._csvHandlers[id]) return this._csvHandlers[id].export(); },
            importCSV: function(id, input) { if (this._csvHandlers[id]) return this._csvHandlers[id].import(input); }
        };

        // Register built-in todos plugin UI contributions
        PluginUI.registerCountProvider('todos', async () => (await Storage.getTodos()).length);
        PluginUI.registerTimerTypeProvider('todos', async () => {
            const lists = await Storage.getTodoLists();
            const todos = (await Storage.getTodos()).filter(t => t.status === 'pending');
            return { lists, todos };
        });
        PluginUI.registerCSVHandlers('todos', exportTodoCSV, importTodoCSV);

        function exportPluginCSV(pluginId) {
            return PluginUI.exportCSV(pluginId);
        }
        function importPluginCSV(pluginId, input) {
            return PluginUI.importCSV(pluginId, input);
        }

@fatherlinux fatherlinux merged commit 7ad1c6e into main Jun 24, 2026
2 of 3 checks passed
@fatherlinux fatherlinux deleted the feature/101-plugin-hardening branch June 24, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin framework hardening: dynamic UI, persistent state, enriched metadata

1 participant