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

ElementDescription
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 buttonLoads the default plugin template and clears the output.
Clear buttonEmpties the editor and the output pane.
▶ Run buttonExecutes 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:

ModeWhat it does
on_heartbeatCalls your on_heartbeat(ctx, heartbeat) function with a mock heartbeat object. Runs in the QuickJS sandbox on the server.
on_tickCalls your on_tick(ctx) function. Use for scheduled background tasks.
on_installCalls your on_install(ctx) function. Simulates a fresh install.
widgetExecutes 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

ShortcutAction
Cmd/Ctrl + EnterRun 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 — a div DOM 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 token argument 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.

PropertyTypeDescription
ctx.user_idstringUUID 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_urlstringBase 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:

FieldTypeDescription
projectstringProject or workspace name
languagestringProgramming language detected
filestringRelative file path (may be empty)
editorstringEditor name, e.g. VS Code, Neovim
branchstringGit branch (may be empty)
osstringOperating system
machinestringMachine identifier (may be empty)
durationnumberDuration in seconds
timenumberUnix 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:

TableDescription
heartbeatsRaw coding activity events
projectsProject metadata
daily_stats_cachePre-computed daily totals
plugin_storePlugin registry entries
installed_pluginsWhich plugins a user has installed
plugin_settingsPer-user plugin configuration (JSONB)
plugin_reviewsUser-written reviews for plugins
usersUser profile and preferences (read/update own record only)
api_keysUser API keys (manage own keys only)
user_followsFollow 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

LimitValue
Memory16 MB per execution
Stack size512 KB
Execution timeout15 seconds (both lifecycle hooks and RPC handlers)
Network accessNone — fetch, XMLHttpRequest, and sockets are not available
Filesystem accessNone — require, fs, and Node.js APIs are not available
Browser globalsNone — window, document, localStorage, etc. are not available
Allowed SQL commandsSELECT, 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 time field 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:

VS Code
IntelliJ IDEA
Neovim
Vim
Cursor
Windsurf
CLion
WebStorm
Eclipse
Sublime Text
Xcode
Android Studio

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 name field 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 version so 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#3f3f46

Cards

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

4h 22m
Rust
<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>

Buttons

Use the .btn class for any interactive action inside your plugin panel.

<!-- Default -->
<button class="btn">Default</button>

<!-- Outlined -->
<button class="btn" style="border-color:var(--border-focus);">Outlined</button>

<!-- Small -->
<button class="btn" style="padding:4px 10px; font-size:11px;">Small</button>

<!-- Tab group -->
<div style="display:flex; gap:16px;">
  <button class="code-tab active">Tab A</button>
  <button class="code-tab">Tab B</button>
</div>

Badges & Pills

Small inline labels for status, language, or category. Compose with inline styles on top of .key-hint.

Rust Live ● Active
<!-- 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.

Rust 62%
TypeScript 28%
<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.

curl -X POST /api/v1/heartbeat \
  -H "X-API-Key: ct_xyz123"
<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.

Importing… 64%
<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.

No data yet
<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

Loading live data...
<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

Waiting for file…
<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!';
};