-
-
Notifications
You must be signed in to change notification settings - Fork 1
Plugin Development
Kinboard's plugin system is build-time, single-maintainer-friendly, and ships with one plugin type: surface plugins. A surface plugin adds a net-new page to the dashboard — its own nav entry, settings flow, optional dashboard widget, and nav-gating predicate. Provider plugins (alternative backends for an existing surface, e.g. an ICS file as a Calendar source) are deliberately deferred until a second concrete provider exists to design against.
The Vehicles feature is the canonical reference implementation. Everything on this page can be cross-checked against webapp/src/plugins/vehicles/.
Build-time, in-repo, not sandboxed. Plugins are registered at build time and run in the same Next.js bundle with full access to every hook and component — the trust model is the same as any other contributor PR. There is no runtime loading, no plugin marketplace, no npm-install path, no dynamic
import(). You ship a plugin by forking, adding files underwebapp/src/, and opening a PR.
| File / location | Purpose |
|---|---|
webapp/src/plugins/<id>/index.ts |
The SurfacePlugin manifest |
webapp/src/plugins/registry.ts |
One import + one array entry to register it |
webapp/src/app/<id>/page.tsx |
The feature page (/<id>) |
webapp/src/app/settings/<id>/ |
The settings flow (/settings/<id>) |
webapp/src/components/widgets/<id>-widget.tsx |
Optional dashboard widget |
webapp/messages/{en,de}.json |
nav.<id>, settings.<id>.*, and your <i18nNamespace> block |
webapp/src/plugins/<id>/drivers/ |
Optional — only for multi-vendor surfaces |
No migrations, no /api/plugins/*, no families column. Per-family config lives in the settings table under a key your plugin owns. Add a migration_<id>.sql only if your plugin needs its own tables.
Implement the SurfacePlugin contract from webapp/src/plugins/types.ts. The Vehicles manifest is the canonical example:
import { Car } from "lucide-react";
import type { SurfacePlugin } from "../types";
import { useVehiclesCount } from "@/hooks/use-vehicles";
import { VehiclesWidget } from "@/components/widgets/vehicles-widget";
export const vehiclesPlugin: SurfacePlugin = {
id: "vehicles",
navItem: {
href: "/vehicles",
icon: Car,
labelKey: "vehicles", // resolves to messages/{locale}.json nav.vehicles
},
settingsItem: {
href: "/settings/vehicles",
icon: Car,
titleKey: "title", // resolves to settings.vehicles.title
descriptionKey: "description",
},
dashboardWidget: VehiclesWidget, // optional
isNavVisible: (ctx) => {
if (ctx.ownDataLoading) return "loading";
return (ctx.ownDataCount ?? 0) > 0;
},
useOwnDataCount: useVehiclesCount,
i18nNamespace: "vehicles",
};useOwnDataCount must return { count: number | undefined; loading: boolean }. If your nav entry doesn't gate on your own row count (e.g. always visible, or gated on ctx.haConnected), return { count: undefined, loading: false }.
import { vehiclesPlugin } from "./vehicles";
import { myPlugin } from "./my-plugin";
export const PLUGINS: readonly SurfacePlugin[] = [
vehiclesPlugin,
myPlugin, // ← add here
];Array order is nav order and /settings/plugins order. This is the only registration step — the settings-landing page lists every entry automatically, and useVisibleNavItems gates each one via its predicate.
"use client", call useKeyboardShortcuts() + useSwipeNavigation() for consistency with other pages, render a <PageHeader>, and — important — a graceful empty/unconfigured state (no blank screen when there's no data yet). See webapp/src/app/vehicles/page.tsx.
At minimum a list/landing page. For multi-item surfaces (more than one row per family, like Vehicles), follow the Vehicles layout: a new/ sub-route for creation and [id]/ for per-item editing.
Config lives in the settings table under a key your plugin owns — read/write it with the shared useSetting / useUpdateSetting hooks (webapp/src/hooks/use-supabase-queries.ts), don't invent a new persistence path. Enable/disable is handled for you: /settings/plugins writes a per-family enabled_plugins blob; gate your page, widget, and any cron-driven work on useIsPluginEnabled("<id>") (webapp/src/hooks/use-enabled-plugins.ts). Plugins are enabled by default (a missing key means on), so a freshly-shipped plugin appears for existing families without a migration.
Add, in both locale files (CI's i18n bundles job fails the PR if EN and DE key sets diverge): nav.<id>, settings.<id>.title + settings.<id>.description, and a top-level "<i18nNamespace>": { … } block for everything your page/settings render.
Set dashboardWidget on the manifest to a ComponentType<object> — it receives no props, it fetches its own data. The registry renders it via webapp/src/plugins/render-dashboard-widgets.tsx. Keep it compact and have it gate itself: useIsPluginEnabled("<id>") (render a "discover" card linking /settings/plugins when disabled — see plugin-discover-card.tsx and how vehicles-widget.tsx / stonks-widget.tsx / pocket-money-widget.tsx use it), and respect the per-device widget_visibility setting.
useVisibleNavItems (webapp/src/hooks/use-visible-nav-items.ts) evaluates each plugin's predicate on every render, passing a NavGatingContext:
type NavGatingContext = {
haConnected: boolean; // HA URL + token saved
haLoading: boolean; // HA status query in flight
ownDataCount: number | undefined; // populated by useOwnDataCount
ownDataLoading: boolean;
};
type NavGatingResult = boolean | "loading";Return true to show, false to hide, "loading" to hide while data is still in flight (prevents the nav from flashing items in/out as React Query resolves).
useVisibleNavItems calls every plugin's useOwnDataCount in fixed order each render. PLUGINS is a module-level, fixed-length readonly array, so that's safe. Never make registration conditional at runtime — a feature flag that adds/removes entries between renders changes the hook-call count and breaks React. Build-time branching (module-eval process.env) is fine.
Some surfaces support multiple vendors with the same data model but different entity IDs / API shapes — Vehicles supports tesla and generic-ev today. Each vendor is a driver: a self-contained implementation of VehicleDriver<TConfig> (webapp/src/plugins/vehicles/drivers/types.ts) providing a Card component, a ConfigForm component, a defaultConfig, an isConfigured predicate, and a displayNameKey. Drivers live under webapp/src/plugins/vehicles/drivers/ and register in that plugin's own drivers/registry.ts — adding a vendor touches no other file.
Add a new plugin (not a driver) when the surface concept itself is different — a Robot-Vacuums plugin is not a Vehicles driver.
registry.ts currently registers five surface plugins: Vehicles, Energy, Cameras, Stonks, and Pocket Money. Energy and Cameras predated the plugin system and were migrated onto the contract once it stabilized — the abstraction is validated across five concrete surfaces. See Plugin-Directory for what each one does.
-
npm run lintis clean (the CI gate). -
npx tsc --noEmitpasses (lint alone won't catch type errors). - EN ↔ DE key parity holds.
- The page and widget have graceful empty/unconfigured/disabled states.
- A
CHANGELOG.md[Unreleased]entry describes the new surface. - If you added tables, the migration is
webapp/docker/migration_<id>.sql, idempotent (IF NOT EXISTS), and sorts after anything it depends on.
- Plugin-Directory — the shipped plugins
- Architecture — where plugins sit in the stack
Kinboard on GitHub · Sponsor · Buy me a coffee · Report a bug · MIT-licensed
Getting started
Operations
Integrations
Kiosk hardware
Built-in features
- Dashboard
- Calendar
- Shopping
- Recipes & meal planning
- Tasks & todos
- Notes
- Birthdays
- School schedule
- Smart home & energy
- Screensaver
- People & devices
- Notifications
- Themes
Plugins (per-family on/off)
Contributing