Documentation
Minimal tracking infrastructure for developers. Direct integration without complex OAuth required.
Plugin Editor
The Plugin Editor (/cteditor) is a browser-based IDE where you can write, test, and preview your plugins before publishing them to the store. It supports both widget plugins (rendered in the browser) and lifecycle plugins (executed server-side in QuickJS).
Opening the editor
Log in and click Plugin Editor in your dashboard navigation. The editor opens with a template already loaded — click Template at any time to reload it.
Interface overview
| Element | Description |
|---|---|
| Editor pane (left) | Monaco-style JavaScript editor with syntax highlighting, bracket matching, and keyboard shortcuts. |
| Output pane (right) | Shows console output, errors, and exit status for lifecycle plugins — or the rendered DOM for widget plugins. |
Template button | Loads the default plugin template and clears the output. |
Clear button | Empties the editor and the output pane. |
▶ Run button | Executes the current code using the selected simulate mode. Shortcut: Cmd+Enter / Ctrl+Enter. |
Simulate modes
Select the trigger to simulate using the buttons in the bottom bar of the editor pane:
| Mode | What it does |
|---|---|
on_heartbeat | Calls your on_heartbeat(ctx, heartbeat) function with a mock heartbeat object. Runs in the QuickJS sandbox on the server. |
on_tick | Calls your on_tick(ctx) function. Use for scheduled background tasks. |
on_install | Calls your on_install(ctx) function. Simulates a fresh install. |
widget | Executes your script in the browser with a live container DOM element and your real token. The output pane renders the widget HTML directly. |
Widget mode — live preview
Switch to widget mode when writing dashboard panel scripts. Your code receives a real container div and your authenticated token, so API calls work exactly as they will in production. The output pane renders the widget live — you can interact with buttons, file inputs, and any DOM elements your script creates.
// Example: renders a live counter widget in the editor output
container.innerHTML = '<div style="font-size:28px; font-family:var(--font-mono);">Loading…</div>';
fetch('/api/v1/stats/summary?range=7d', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(r => r.json())
.then(data => {
const s = data.total_seconds || 0;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
container.querySelector('div').textContent = h + 'h ' + m + 'm';
});
Lifecycle mode — QuickJS sandbox
When using on_heartbeat, on_tick, or on_install modes, the code is sent to the server and executed inside an isolated QuickJS runtime with no access to the filesystem, network, or OS. Available globals are ctx, heartbeat, log(), warn(), and error(). Any call to log() appears in the Output pane.
async function on_heartbeat(ctx, heartbeat) {
log('Project:', heartbeat.project);
log('Language:', heartbeat.language);
// Real DB query — runs server-side
const rows = await ctx.db.query(
'SELECT COUNT(*) as n FROM heartbeats WHERE user_id = $1',
[ctx.user_id]
);
log('Total heartbeats:', rows[0].n);
}
Keyboard shortcuts
| Shortcut | Action |
|---|---|
Cmd/Ctrl + Enter | Run code |
Cmd/Ctrl + / | Toggle comment |
Widget Plugins
Widget plugins add custom panels to a user's dashboard. They are plain JavaScript scripts executed
directly in the user's browser. You have full DOM access, fetch, and any standard
browser API. No server recompilation, no external hosting required.
How it works
When a user visits their dashboard, CodeTrackr fetches all their installed plugins and runs each plugin's script in the browser. Your script receives two arguments:
container— adivDOM element where your plugin should render its content.token— the user's Bearer token, so you can call the CodeTrackr API authenticated.
Your script must populate container with whatever HTML or text you want to display. The
script body is executed directly — you do not need to define a function named
render; just write the logic at the top level.
Step 1 — Write your script
The simplest possible plugin displays a static counter:
container.textContent = 'Hello from my plugin!';
A real plugin calls the CodeTrackr API using the provided token. Here is an example that shows the user's total coding time this week:
container.textContent = 'Loading…';
fetch('/api/v1/stats/summary?range=7d', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) { return r.json(); })
.then(function(data) {
var s = data.total_seconds || 0;
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
container.textContent = h + 'h ' + m + 'm this week';
})
.catch(function() {
container.textContent = '—';
});
Step 2 — Build the panel HTML (optional)
You can set innerHTML to render richer UI using the design tokens from
main.css. The container already lives inside a .plugin-panel card.
fetch('/api/v1/stats/languages?range=7d', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) { return r.json(); })
.then(function(data) {
var langs = (data.languages || []).slice(0, 3);
var rows = langs.map(function(l) {
return '<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:6px;">'
+ '<span style="color:var(--text-main);">' + l.language + '</span>'
+ '<span style="color:var(--text-dark);">' + l.percentage.toFixed(1) + '%</span>'
+ '</div>';
}).join('');
container.innerHTML = rows || '<span style="color:var(--text-muted);">No data yet</span>';
});
Step 3 — Available API endpoints
Your script can call any authenticated CodeTrackr endpoint using the token argument.
The most useful ones for plugins are:
| Endpoint | Description |
|---|---|
GET /api/v1/stats/summary?range=7d |
Total time, top language, top project for a period (7d, 30d, all) |
GET /api/v1/stats/languages?range=7d |
Language breakdown with percentages |
GET /api/v1/stats/daily?range=7d |
Per-day seconds array for chart rendering |
GET /api/v1/stats/projects |
Time per project with last heartbeat |
GET /api/v1/stats/streaks |
Current and longest coding streaks |
GET /api/v1/user/me |
Authenticated user profile |
All requests require the header Authorization: Bearer <token>, which is provided
automatically via the token argument.
Step 4 — Handle errors gracefully
Always handle fetch errors and empty states so the panel does not break the dashboard:
fetch('/api/v1/stats/streaks', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) {
if (!r.ok) throw new Error('API error');
return r.json();
})
.then(function(data) {
container.textContent = (data.current_streak || 0) + ' day streak 🔥';
})
.catch(function() {
container.innerHTML = '<span style="color:var(--text-muted);">—</span>';
});
Step 5 — Test in the editor first
Open the Plugin Editor, paste your script, select widget mode in the simulate bar, and click ▶ Run. The output pane renders your widget live with your real token — full interactive preview before publishing.
Step 6 — Publish your plugin
Once your script is ready, publish it through the dashboard UI or via the API:
- Log in and click + Publish Plugin in your dashboard.
- Fill in the name (kebab-case, e.g.
streak-counter), display name, description, version, icon emoji, and widget type. - Paste your JavaScript code in the Plugin Script field.
- Click Publish.
Or publish directly via the API:
curl -X POST https://codetrackr.dev/api/v1/store/publish \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "streak-counter",
"display_name": "Streak Counter",
"description": "Shows your current coding streak.",
"version": "1.0.0",
"icon": "🔥",
"widget_type": "counter",
"script": "fetch(\"/api/v1/stats/streaks\",{headers:{\"Authorization\":\"Bearer \"+token}}).then(r=>r.json()).then(d=>{container.textContent=(d.current_streak||0)+\" day streak\";}).catch(()=>{container.textContent=\"—\";});"
}'
Step 7 — Install and test
After publishing, find your plugin in the Plugin Store section of the home page and
click Install. Go to your dashboard — the panel will appear automatically. If the
script has a runtime error, a warning ⚠ Plugin error will be shown and the full error
will be logged to the browser console.
Security constraints
- Scripts run inside
new Function()— they have access to standard browser APIs but cannot access variables from the CodeTrackr application scope. - Plugins that are reported and banned by admins will stop loading for all users.
- Never hardcode secrets in your script — the
tokenargument is always the authenticated user's own token. - External fetch calls from the script are allowed (runs in the user's browser), but consider that users may have privacy extensions that block cross-origin requests.
Full example — Daily Activity Bars
A complete plugin that renders a 7-day bar chart inside the panel:
var bar_style = 'flex:1;background:var(--border-focus);border-radius:2px 2px 0 0;min-height:2px;';
container.innerHTML = '<div id="ct-bars" style="display:flex;align-items:flex-end;gap:4px;height:60px;"></div>';
fetch('/api/v1/stats/daily?range=7d', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) { return r.json(); })
.then(function(data) {
var daily = data.daily || [];
if (daily.length === 0) {
container.textContent = 'No data yet';
return;
}
var max = Math.max.apply(null, daily.map(function(d) { return d.seconds; })) || 1;
var barsEl = document.getElementById('ct-bars');
if (!barsEl) return;
barsEl.innerHTML = daily.map(function(d) {
var pct = Math.max((d.seconds / max) * 100, 2).toFixed(1);
var h = Math.floor(d.seconds / 3600);
var m = Math.floor((d.seconds % 3600) / 60);
return '<div title="' + d.date + ' · ' + h + 'h ' + m + 'm" style="' + bar_style + 'height:' + pct + '%;"></div>';
}).join('');
})
.catch(function() {
container.textContent = '—';
});
Lifecycle Plugins
Lifecycle plugins run server-side inside an embedded QuickJS sandbox. They react to events like incoming heartbeats, periodic ticks, or a fresh install, and can expose custom HTTP endpoints. Unlike widget plugins, they have no DOM or browser access — instead they receive a ctx object with real database and Redis access scoped to the authenticated user.
When to use lifecycle plugins
- Aggregate or transform user data every time a heartbeat arrives.
- Run scheduled background logic on a periodic tick.
- Set up initial state the moment a user installs your plugin.
- Expose custom POST endpoints callable from widget plugins or external tools.
- Let users trigger account-level actions (delete data, export stats, etc.) via RPC.
The ctx object
Every handler receives ctx as its first argument. All properties are scoped to the authenticated user — you can never read or modify data belonging to other users.
| Property | Type | Description |
|---|---|---|
ctx.user_id | string | UUID of the authenticated user who owns this plugin instance. |
ctx.db.query(sql, params) | Promise<Row[]> | Execute a parameterized PostgreSQL query. Returns an array of row objects. SQL must target allowed tables only (see below). |
ctx.redis.get(key) | Promise<string|null> | Get a value from your plugin's private Redis namespace. |
ctx.redis.set(key, value) | Promise<void> | Set a key in your plugin's private Redis namespace. |
ctx.redis.del(key) | Promise<void> | Delete a key from your plugin's private Redis namespace. |
ctx.redis.incr(key) | Promise<number> | Atomically increment an integer counter in your private namespace. |
ctx.config.base_url | string | Base URL of the CodeTrackr instance, e.g. https://codetrackr.leapcell.app. |
Lifecycle hooks
Export any combination of these three functions. You do not need to export all of them — only the ones your plugin uses.
// Called every time a heartbeat is received for this user.
async function on_heartbeat(ctx, heartbeat) {
log('Heartbeat from', heartbeat.editor, 'on', heartbeat.project);
}
// Called on a periodic server-side tick (approximately every minute).
async function on_tick(ctx) {
log('Tick for user', ctx.user_id);
}
// Called exactly once when a user installs your plugin.
async function on_install(ctx) {
log('Plugin installed for', ctx.user_id);
}
The heartbeat object
on_heartbeat receives the raw heartbeat that was just recorded:
| Field | Type | Description |
|---|---|---|
project | string | Project or workspace name |
language | string | Programming language detected |
file | string | Relative file path (may be empty) |
editor | string | Editor name, e.g. VS Code, Neovim |
branch | string | Git branch (may be empty) |
os | string | Operating system |
machine | string | Machine identifier (may be empty) |
duration | number | Duration in seconds |
time | number | Unix timestamp of the activity |
Logging
Use log(), warn(), and error() to emit output. In the Plugin Editor they appear in the Output pane in real time. In production they are written to the server trace log — not visible to end users.
log('Hello', 'world'); // Hello world
warn('Something unusual'); // [WARN] Something unusual
error('Something broke'); // [ERROR] Something broke
// All functions accept multiple arguments — they are joined with spaces:
log('Project:', heartbeat.project, '— duration:', heartbeat.duration + 's');
Database queries
Use ctx.db.query(sql, params) with positional placeholders ($1, $2, …). Parameters are always passed as an array. The query runs synchronously inside the sandbox and returns an array of plain row objects.
Always filter by ctx.user_id — the sandbox does not add a user filter automatically. Queries that omit a user scope will read data from all users.
async function on_heartbeat(ctx, heartbeat) {
// Count total heartbeats for this user on this project
const rows = await ctx.db.query(
`SELECT COUNT(*) as n, SUM(duration_seconds) as total_secs
FROM heartbeats
WHERE user_id = $1 AND project = $2`,
[ctx.user_id, heartbeat.project]
);
log('Sessions on', heartbeat.project + ':', rows[0].n, '— total', rows[0].total_secs + 's');
}
Allowed database tables
For security, plugins can only query the following tables. Attempts to access any other table are rejected with an error before the query reaches the database:
| Table | Description |
|---|---|
heartbeats | Raw coding activity events |
projects | Project metadata |
daily_stats_cache | Pre-computed daily totals |
plugin_store | Plugin registry entries |
installed_plugins | Which plugins a user has installed |
plugin_settings | Per-user plugin configuration (JSONB) |
plugin_reviews | User-written reviews for plugins |
users | User profile and preferences (read/update own record only) |
api_keys | User API keys (manage own keys only) |
user_follows | Follow relationships between users |
Allowed SQL commands are SELECT, INSERT, UPDATE, and DELETE. DDL commands (DROP, CREATE, ALTER, TRUNCATE, etc.) are always rejected.
Redis — private namespace
All Redis keys are automatically namespaced to your plugin and user. The key you pass to ctx.redis.* is prefixed with plugin:<plugin-name>:<user-id>: before any operation — you cannot read or overwrite keys belonging to another plugin or another user.
async function on_heartbeat(ctx, heartbeat) {
// Stored as: plugin:my-plugin:<user-id>:count:<project>
const count = await ctx.redis.incr('count:' + heartbeat.project);
log('Heartbeat #' + count + ' on', heartbeat.project);
// Cache last seen language
await ctx.redis.set('last-lang', heartbeat.language);
}
async function on_tick(ctx) {
const lang = await ctx.redis.get('last-lang');
log('Last language:', lang || 'none');
}
async function on_install(ctx) {
// Run once — initialize default state
await ctx.redis.set('installed-at', new Date().toISOString());
}
Plugin settings (per-user config)
Store user-configurable settings in the plugin_settings table. The settings column is a JSONB object — query and update it like any other table.
async function on_install(ctx) {
// Create default settings for this user on install
await ctx.db.query(
`INSERT INTO plugin_settings (user_id, plugin, settings)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, plugin) DO NOTHING`,
[ctx.user_id, 'my-plugin', JSON.stringify({ notify: true, threshold: 3600 })]
);
}
async function on_tick(ctx) {
const rows = await ctx.db.query(
'SELECT settings FROM plugin_settings WHERE user_id = $1 AND plugin = $2',
[ctx.user_id, 'my-plugin']
);
const settings = rows[0]?.settings || {};
if (settings.notify) {
log('Notifications enabled — threshold:', settings.threshold, 's');
}
}
Custom HTTP endpoints (RPC)
Declare an endpoints object to expose custom POST routes. Each key becomes a handler available at:
POST /api/v1/plugins/<plugin-name>/rpc/<handler-name>
Every handler receives (ctx, req) where req is the parsed JSON body of the request. Return any JSON-serializable value — it becomes the response body.
const endpoints = {
// POST /api/v1/plugins/my-plugin/rpc/stats
'stats': async (ctx, req) => {
const rows = await ctx.db.query(
`SELECT COUNT(*) as sessions, SUM(duration_seconds) as total_secs
FROM heartbeats WHERE user_id = $1`,
[ctx.user_id]
);
return {
sessions: rows[0].sessions,
total_hours: (rows[0].total_secs / 3600).toFixed(1)
};
},
// POST /api/v1/plugins/my-plugin/rpc/set-threshold
// Body: { "threshold": 7200 }
'set-threshold': async (ctx, req) => {
const threshold = Number(req.threshold) || 3600;
await ctx.db.query(
`UPDATE plugin_settings SET settings = settings || $1
WHERE user_id = $2 AND plugin = $3`,
[JSON.stringify({ threshold }), ctx.user_id, 'my-plugin']
);
return { ok: true, threshold };
},
// POST /api/v1/plugins/my-plugin/rpc/delete-account
'delete-account': async (ctx, req) => {
await ctx.db.query(
'DELETE FROM users WHERE id = $1',
[ctx.user_id]
);
return { ok: true };
},
};
Call your endpoint with Bearer auth:
# No body required
curl -X POST https://codetrackr.leapcell.app/api/v1/plugins/my-plugin/rpc/stats \
-H "Authorization: Bearer YOUR_TOKEN"
# With a JSON body
curl -X POST https://codetrackr.leapcell.app/api/v1/plugins/my-plugin/rpc/set-threshold \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "threshold": 7200 }'
Handler names may only contain alphanumeric characters, hyphens, and underscores. Any other characters are stripped before dispatch.
Calling RPC from a widget plugin
Lifecycle RPC endpoints are ideal as the data backend for your widget plugin. Call them from the widget script using the provided token:
// In your widget script (runs in the browser)
fetch('/api/v1/plugins/my-plugin/rpc/stats', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
})
.then(r => r.json())
.then(data => {
container.textContent = data.total_hours + 'h total';
})
.catch(() => {
container.textContent = '—';
});
Sandbox limits
| Limit | Value |
|---|---|
| Memory | 16 MB per execution |
| Stack size | 512 KB |
| Execution timeout | 15 seconds (both lifecycle hooks and RPC handlers) |
| Network access | None — fetch, XMLHttpRequest, and sockets are not available |
| Filesystem access | None — require, fs, and Node.js APIs are not available |
| Browser globals | None — window, document, localStorage, etc. are not available |
| Allowed SQL commands | SELECT, INSERT, UPDATE, DELETE |
Error handling
Unhandled exceptions inside a handler are caught by the sandbox and returned as an error response. Always wrap risky logic in try/catch for clean error messages:
const endpoints = {
'safe-query': async (ctx, req) => {
try {
const rows = await ctx.db.query(
'SELECT COUNT(*) as n FROM heartbeats WHERE user_id = $1',
[ctx.user_id]
);
return { count: rows[0].n };
} catch (e) {
error('Query failed:', e.message);
return { error: 'Could not load data' };
}
}
};
Full example — Streak notifier with RPC
A complete lifecycle plugin that tracks the user's coding streak in Redis, checks for activity on every tick, and exposes an RPC endpoint so a widget plugin can display the streak count.
// ── on_install: initialize Redis state ───────────────────────────────────────
async function on_install(ctx) {
await ctx.redis.set('streak', '0');
await ctx.redis.set('last-day', '');
log('Streak Notifier installed for', ctx.user_id);
}
// ── on_tick: check daily activity and update streak ───────────────────────────
async function on_tick(ctx) {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const lastDay = await ctx.redis.get('last-day');
if (lastDay === today) {
return; // Already checked today
}
const rows = await ctx.db.query(
`SELECT 1 FROM heartbeats
WHERE user_id = $1
AND recorded_at >= NOW() - INTERVAL '1 day'
LIMIT 1`,
[ctx.user_id]
);
if (rows.length > 0) {
const streak = await ctx.redis.incr('streak');
await ctx.redis.set('last-day', today);
log('Day ' + streak + ' of streak — keep it up!');
} else {
warn('No activity today — streak at risk!');
}
}
// ── on_heartbeat: reset risk flag when a heartbeat arrives ───────────────────
async function on_heartbeat(ctx, heartbeat) {
const today = new Date().toISOString().slice(0, 10);
await ctx.redis.set('last-day', today);
}
// ── RPC: widget can call this to display the streak ──────────────────────────
const endpoints = {
'get-streak': async (ctx, req) => {
const streak = await ctx.redis.get('streak');
const lastDay = await ctx.redis.get('last-day');
return {
streak: parseInt(streak || '0', 10),
last_day: lastDay || null
};
},
'reset-streak': async (ctx, req) => {
await ctx.redis.set('streak', '0');
await ctx.redis.set('last-day', '');
log('Streak reset by user', ctx.user_id);
return { ok: true };
}
};
Call GET-streak from your widget plugin to show the live streak count:
// Widget script
fetch('/api/v1/plugins/streak-notifier/rpc/get-streak', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
})
.then(r => r.json())
.then(data => {
container.textContent = data.streak + ' day streak 🔥';
})
.catch(() => { container.textContent = '—'; });
Import Data
The POST /api/v1/import endpoint accepts an array of heartbeats in JSON format, allowing you to migrate data from any external tool. Authentication is via API Key or Bearer token.
Request
curl -X POST https://codetrackr.dev/api/v1/import \
-H "X-API-Key: ct_xyz123" \
-H "Content-Type: application/json" \
-d '[
{
"project": "my-app",
"lang": "rust",
"file": "src/main.rs",
"branch": "main",
"editor": "Windsurf",
"os": "macOS",
"time": 1741276680,
"duration": 60
}
]'
Payload fields
| Field | Required | Description |
|---|---|---|
project |
Yes | Project or workspace name |
lang |
No | Language identifier, e.g. rust, html |
file |
No | Relative file path |
branch |
No | Git branch name |
commit |
No | Git commit SHA |
workspace_root |
No | Git remote URL or workspace root path |
editor |
No | Editor name, e.g. Windsurf, VS Code |
os |
No | Operating system, e.g. macOS, Linux |
machine |
No | Machine identifier |
time |
No | Unix timestamp of the activity. Defaults to current time if omitted |
duration |
No | Duration in seconds. Defaults to 60 |
is_write |
No | Boolean — whether the event was a write action. Defaults to false |
Response
{ "status": "ok", "inserted": 42 }
Notes
- Duplicate entries are silently ignored (
ON CONFLICT DO NOTHING). - You can send up to thousands of records in a single request — batch large imports to avoid timeouts.
- The
timefield accepts Unix timestamps (float or integer). If omitted, the server time is used. - This endpoint is intentionally format-agnostic: write a small script to convert any source (WakaTime, CodeTime, custom logs) into this schema and POST it.
IDE Extensions API
Drop a standard POST payload to the /api/v1/heartbeat endpoint with your API Key to
track activity anywhere.
curl -X POST https://codetrackr.dev/api/v1/heartbeat \
-H "X-API-Key: ct_xyz123" \
-H "Content-Type: application/json" \
-d '{
"project": "core-engine",
"language": "rust",
"duration": 60
}'
Supported Environments
You can build or use existing wrappers for an extensive list of platforms:
Bulk Pipeline Updates
If your environment caches offline time, you can bulk post logs to /api/v1/heartbeats to
restore histories seamlessly.
The Plugin Store
The Plugin Store is a community directory of dashboard plugins. Any authenticated user can publish and install plugins. No server access or recompilation needed.
- Browse: All published plugins are visible on the home page under Plugin Store, no login required.
- Install: One-click install. Installed plugins appear immediately on your dashboard next time you load it.
- Uninstall: Click the
✕button on any panel in your dashboard to remove it. - Privacy: Plugin scripts run entirely in the user's browser. CodeTrackr's servers never make requests to external services on your behalf.
- Moderation: Users can report plugins. Admins can ban or delete them. Banned plugins stop loading for all users immediately.
Publishing to the Store
You must be authenticated via GitHub or GitLab to publish. Use the + Publish Plugin button in your dashboard, or call the API directly:
curl -X POST https://codetrackr.dev/api/v1/store/publish \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "my-cool-widget",
"display_name": "Project Velocity",
"description": "Visualize your commit velocity over time.",
"version": "1.0.0",
"icon": "📈",
"widget_type": "counter",
"repository": "https://github.com/you/my-cool-widget",
"script": "container.textContent = \"Loading...\"; fetch(\"/api/v1/stats/summary?range=7d\",{headers:{\"Authorization\":\"Bearer \"+token}}).then(r=>r.json()).then(d=>{container.textContent=d.top_project||\"—\";}).catch(()=>{container.textContent=\"—\";});"
}'
Publish fields reference
| Field | Required | Description |
|---|---|---|
name |
Yes | Unique kebab-case identifier, e.g. streak-counter |
display_name |
Yes | Human-readable title shown in the store and dashboard panel header |
description |
No | Short description shown in the store card |
version |
No | Semver string, defaults to 0.1.0 |
icon |
No | Single emoji shown next to the plugin title, defaults to 🔌 |
widget_type |
No | counter · chart · list · text · progress |
repository |
No | URL to the plugin's source code |
script |
No | JavaScript code executed in the user's browser. Receives container (DOM element) and token (Bearer token string) |
Updating an existing plugin
Publishing with the same name performs an in-place update (upsert). All users who have
the plugin installed will receive the new script on their next dashboard load — no reinstall
required.
To update, call the same publish endpoint with the identical name and whatever fields
you want to change:
curl -X POST https://codetrackr.dev/api/v1/store/publish \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "streak-counter",
"display_name": "Streak Counter",
"version": "1.1.0",
"script": "/* updated script */"
}'
- The
namefield is the unique key — it must match exactly (lowercase kebab-case). - Only the fields you include are updated; omitted optional fields keep their previous values.
- Bump
versionso users can see a changelog in the store card. - Only the original publisher (same authenticated user) can overwrite a plugin.
Design Tokens
Include the main stylesheet and reference these CSS custom properties in your plugin HTML. All components below depend on them.
<link rel="stylesheet" href="/static/css/main.css" />
--bg#121212--bg-card#18181b--bg-hover#27272a--text-main#ffffff--text-muted#a1a1aa--text-dark#52525b--border#27272a--border-focus#3f3f46Cards
Base container for any plugin panel. Use the .card class directly.
Plugin Title
A short description or metric value goes here.
<div class="card">
<h3>Plugin Title</h3>
<p>A short description or metric value.</p>
</div>
Grid Layout
Use .grid-2 or .grid-3 to lay out multiple cards side by side.
<div class="grid-2">
<div class="card">...</div>
<div class="card">...</div>
</div>
<div class="grid-3">
<div class="card">...</div>
<div class="card">...</div>
<div class="card">...</div>
</div>
Stat Widget
The standard counter card used in the main dashboard. Combines a label, a large mono value, and an optional sub-label.
Today
<div class="card">
<h3 style="font-size:11px; text-transform:uppercase;
color:var(--text-dark); margin-bottom:8px;">
Label
</h3>
<div style="font-size:28px; font-family:var(--font-mono);
color:var(--text-main);">
4h 22m
</div>
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
Sub-label
</div>
</div>
Badges & Pills
Small inline labels for status, language, or category. Compose with inline styles on top of
.key-hint.
<!-- Mono badge (keyboard hint style) -->
<span class="key-hint">Rust</span>
<!-- Star / special -->
<span class="key-hint">★</span>
<!-- Live indicator -->
<span style="font-size:10px; color:var(--text-dark);">● Live</span>
Tables
Minimal mono-font table with hover rows. Wrap in .table-wrapper for horizontal scroll
on small viewports.
| Project | Language | Time |
|---|---|---|
| core-engine | Rust | 3h 12m |
| dashboard-ui | TypeScript | 1h 44m |
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Project</th>
<th>Language</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr>
<td class="td-main">core-engine</td>
<td>Rust</td>
<td>3h 12m</td>
</tr>
</tbody>
</table>
</div>
Bar Charts
Vertical bar chart used for daily activity. Each bar is a flex column with a percentage height and a
tooltip via title.
<div style="display:flex; align-items:flex-end; gap:6px; height:80px;">
<!-- height% = (minutes / max_minutes) * 100 -->
<div title="Mon · 1h 20m"
style="flex:1; background:var(--border-focus);
border-radius:2px 2px 0 0; height:35%;"></div>
<div title="Tue · 3h 10m"
style="flex:1; background:var(--border-focus);
border-radius:2px 2px 0 0; height:80%;"></div>
<!-- repeat for each day -->
</div>
Language Progress Bars
Horizontal fill bars for language breakdowns inside a card.
<div style="display:flex; flex-direction:column; gap:10px;">
<div>
<div style="display:flex; justify-content:space-between;
font-size:12px; font-family:var(--font-mono); margin-bottom:4px;">
<span style="color:var(--text-main);">Rust</span>
<span style="color:var(--text-dark);">62%</span>
</div>
<div style="height:3px; background:var(--border); border-radius:99px;">
<div style="width:62%; height:100%;
background:var(--border-focus); border-radius:99px;"></div>
</div>
</div>
</div>
Code Blocks
Use a <pre><code> pair. The pre style is defined globally in
main.css — no extra class needed.
<pre><code>your code here</code></pre>
Inline Code
<code>/api/v1/heartbeat</code>
Tab Group + Code Block
Combine .code-tabs and .code-tab above a pre to switch
between snippets.
<div style="border:1px solid var(--border); border-radius:var(--radius); overflow:hidden;">
<div class="code-tabs">
<button class="code-tab active">curl</button>
<button class="code-tab">Python</button>
</div>
<pre style="margin:0; border:none; border-radius:0;">
<code>your snippet</code>
</pre>
</div>
Progress Bars
Horizontal fill bars for progress indicators, file uploads, or import operations inside a widget plugin.
<div style="width:100%; height:6px; background:var(--border);
border-radius:4px; overflow:hidden;">
<div id="progressBar"
style="width:0%; height:100%; background:var(--border-focus);
border-radius:4px; transition:width .2s;"></div>
</div>
Update from JavaScript:
const bar = container.querySelector('#progressBar');
bar.style.width = '64%';
Empty States
Show when a plugin panel has no data yet. Keep it minimal and mono.
<div class="card" style="text-align:center; padding:40px 24px;">
<div style="font-size:24px; margin-bottom:12px;">—</div>
<div style="font-size:12px; color:var(--text-dark);
font-family:var(--font-mono);">
No data yet
</div>
</div>
Loading State
<p style="color:var(--text-muted);">Loading live data...</p>
Forms & Inputs
Use standard HTML inputs styled with the design tokens. All inputs inside your widget plugin inherit the global stylesheet from main.css.
Text input
<input type="text" placeholder="Enter value…"
style="width:100%; background:var(--bg);
border:1px solid var(--border);
border-radius:var(--radius-sm);
padding:6px 10px; font-size:13px;
font-family:var(--font-mono);
color:var(--text-main); outline:none;">
File input
<input type="file" accept=".csv"
style="font-size:12px; font-family:var(--font-mono);
color:var(--text-muted);">
Status label
<div id="status"
style="font-size:12px; color:var(--text-muted);
font-family:var(--font-mono);">
Waiting for file…
</div>
Full widget form example
A complete import widget using progress bar, file input, button, and status label:
container.innerHTML = `
<div style="display:flex; flex-direction:column; gap:10px; max-width:420px;">
<input type="file" id="csvFile" accept=".csv"
style="font-size:12px; font-family:var(--font-mono); color:var(--text-muted);">
<button class="btn" id="importBtn" disabled
style="display:inline-flex; padding:4px 12px; font-size:12px;">
Import CSV
</button>
<div style="width:100%; height:6px; background:var(--border);
border-radius:4px; overflow:hidden;">
<div id="progressBar" style="width:0%; height:100%; background:var(--border-focus);
border-radius:4px; transition:width .2s;"></div>
</div>
<div id="status" style="font-size:12px; color:var(--text-muted);"></div>
</div>
`;
const fileInput = container.querySelector('#csvFile');
const button = container.querySelector('#importBtn');
const status = container.querySelector('#status');
const bar = container.querySelector('#progressBar');
fileInput.onchange = () => {
if (fileInput.files.length) {
button.disabled = false;
status.textContent = 'Ready: ' + fileInput.files[0].name;
}
};
button.onclick = async () => {
status.textContent = 'Uploading…';
// … your fetch logic here …
bar.style.width = '100%';
status.textContent = 'Done!';
};