Skip to content

Commit 197c74d

Browse files
committed
feat: add per-project time filtering and project breakdown to codetime tool
1 parent c0027a6 commit 197c74d

3 files changed

Lines changed: 239 additions & 6 deletions

File tree

README.md

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414

1515
- **Automatic time tracking** -- sends coding events to CodeTime when OpenCode reads, edits, or writes files
1616
- **Check your coding time** -- ask the AI "what's my coding time?" and it fetches your stats via the `codetime` tool
17+
- **Per-project filtering** -- view coding time for the current project or any specific project
18+
- **Project breakdown** -- see a ranked table of all projects with time spent today
1719
- **Language detection** -- detects 90+ programming languages from file extensions
1820
- **Git integration** -- captures current branch and remote origin
19-
- **Project identification** -- shows as `[opencode] project-name` on your CodeTime dashboard
21+
- **Project identification** -- shows as `directory-name [opencode]` on your CodeTime dashboard
2022
- **Rate-limited** -- one heartbeat per 2 minutes to avoid API spam
2123
- **Session lifecycle** -- flushes pending events on session end so no data is lost
2224
- **Zero config** -- just set your token and go
@@ -91,7 +93,7 @@ Each heartbeat sent to CodeTime includes:
9193
|-------|-------|
9294
| `eventTime` | Unix timestamp of the event |
9395
| `language` | Detected from file extension (e.g. `TypeScript`, `Python`) |
94-
| `project` | `[opencode] directory-name` |
96+
| `project` | `directory-name [opencode]` |
9597
| `relativeFile` | File path relative to the project root |
9698
| `editor` | `opencode` |
9799
| `platform` | OS platform (`darwin`, `linux`, `windows`) |
@@ -106,6 +108,48 @@ Each heartbeat sent to CodeTime includes:
106108
| `chat.message` | Processes pending heartbeats on chat activity |
107109
| `tool` | Registers `codetime` tool to check today's coding time |
108110

111+
### Commands
112+
113+
| Command | Description |
114+
|---------|-------------|
115+
| `/codetime` | Show today's total coding time |
116+
| `/codetime-breakdown` | Show today's coding time broken down by project |
117+
118+
### `codetime` tool
119+
120+
The `codetime` tool supports optional arguments for project filtering:
121+
122+
| Argument | Type | Description |
123+
|----------|------|-------------|
124+
| `project` | string (optional) | Filter by project name. Use `"current"` to auto-detect the current project. Omit to show total time. |
125+
| `breakdown` | boolean (optional) | When `true`, show a ranked breakdown of all projects. |
126+
127+
**Usage examples** (in natural language to the AI):
128+
129+
- "What's my coding time?" -- shows total time across all projects
130+
- "How long have I been coding on this project?" -- shows time for the current project
131+
- "Show me a breakdown of my coding time by project" -- shows ranked project list
132+
- "How much time did I spend on my-app today?" -- shows time for a specific project
133+
134+
**Example outputs:**
135+
136+
```
137+
# Total time (default)
138+
Today's coding time: 2h 42m
139+
140+
# Current project
141+
Today's coding time for opencode-codetime [opencode]: 1h 23m (Total across all projects: 2h 42m)
142+
143+
# Project breakdown
144+
Today's coding time by project:
145+
146+
opencode-codetime [opencode] 1h 23m
147+
my-other-project [vscode] 52m
148+
side-project [opencode] 27m
149+
──────────────────────────────────────
150+
Total 2h 42m
151+
```
152+
109153
### Tool tracking
110154

111155
| Tool | Data Extracted |
@@ -121,7 +165,7 @@ Each heartbeat sent to CodeTime includes:
121165
```
122166
src/
123167
index.ts Main plugin entry point with event hooks
124-
codetime.ts CodeTime API client (token validation, heartbeats)
168+
codetime.ts CodeTime API client (token validation, heartbeats, stats)
125169
state.ts Rate limiter (max 1 heartbeat per 2 minutes)
126170
language.ts File extension to language name mapping
127171
git.ts Git branch and remote origin extraction

src/codetime.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,114 @@ export async function getTodayMinutes(
118118
return null;
119119
}
120120
}
121+
122+
// ---- Response types for stats/top endpoints ----
123+
124+
export interface StatsTimeData {
125+
duration: number;
126+
time: string;
127+
}
128+
129+
export interface StatsTimeResponse {
130+
data: StatsTimeData[];
131+
}
132+
133+
export interface TopEntry {
134+
field: string;
135+
minutes: number;
136+
}
137+
138+
/**
139+
* Fetch coding minutes for a specific project using the stats_time endpoint.
140+
* Returns total minutes for the project within the last 24 hours,
141+
* or a custom time range if start/end are provided.
142+
*/
143+
export async function getProjectMinutes(
144+
token: string,
145+
project: string,
146+
): Promise<number | null> {
147+
try {
148+
const params = new URLSearchParams({
149+
project,
150+
unit: "minutes",
151+
limit: "1440",
152+
});
153+
154+
const response = await fetch(
155+
`${API_BASE}/v3/users/self/stats_time?${params.toString()}`,
156+
{
157+
method: "GET",
158+
headers: {
159+
Authorization: `Bearer ${token}`,
160+
"Content-Type": "application/json",
161+
},
162+
},
163+
);
164+
165+
if (!response.ok) {
166+
await logger.warn("Failed to fetch project minutes", {
167+
status: response.status,
168+
statusText: response.statusText,
169+
project,
170+
});
171+
return null;
172+
}
173+
174+
const data = (await response.json()) as StatsTimeResponse;
175+
// Sum all duration values from the response
176+
const totalMinutes = data.data.reduce(
177+
(sum, entry) => sum + entry.duration,
178+
0,
179+
);
180+
return totalMinutes;
181+
} catch (err) {
182+
await logger.error("Project minutes request failed", {
183+
error: String(err),
184+
project,
185+
});
186+
return null;
187+
}
188+
}
189+
190+
/**
191+
* Fetch top projects by coding time using the top endpoint.
192+
* Returns a ranked list of projects with their minutes.
193+
*/
194+
export async function getTopProjects(
195+
token: string,
196+
minutes: number = 1440,
197+
): Promise<TopEntry[] | null> {
198+
try {
199+
const params = new URLSearchParams({
200+
field: "workspace",
201+
minutes: String(minutes),
202+
});
203+
204+
const response = await fetch(
205+
`${API_BASE}/v3/users/self/top?${params.toString()}`,
206+
{
207+
method: "GET",
208+
headers: {
209+
Authorization: `Bearer ${token}`,
210+
"Content-Type": "application/json",
211+
},
212+
},
213+
);
214+
215+
if (!response.ok) {
216+
await logger.warn("Failed to fetch top projects", {
217+
status: response.status,
218+
statusText: response.statusText,
219+
});
220+
return null;
221+
}
222+
223+
const data = (await response.json()) as TopEntry[];
224+
return data;
225+
} catch (err) {
226+
await logger.error("Top projects request failed", {
227+
error: String(err),
228+
});
229+
return null;
230+
}
231+
}

src/index.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
sendHeartbeat,
77
validateToken,
88
getTodayMinutes,
9+
getProjectMinutes,
10+
getTopProjects,
911
type EventLogRequest,
1012
} from "./codetime.js";
1113
import { getGitBranch, getGitOrigin } from "./git.js";
@@ -344,21 +346,97 @@ export const plugin: Plugin = async (ctx) => {
344346
"Immediately call `codetime` with no arguments and return its output verbatim.\n" +
345347
"Do not call other tools.",
346348
};
349+
cfg.command["codetime-breakdown"] = {
350+
description: "Show today's coding time breakdown by project",
351+
template:
352+
"Retrieve CodeTime coding time stats broken down by project.\n\n" +
353+
'Immediately call `codetime` with `breakdown: true` and return its output verbatim.\n' +
354+
"Do not call other tools.",
355+
};
347356
},
348357

349358
tool: {
350359
codetime: tool({
351360
description:
352361
"Show today's coding time tracked by CodeTime. " +
353362
"Use this when the user asks about their coding time, " +
354-
"how long they've been coding, or wants to see their CodeTime stats.",
355-
args: {},
356-
async execute() {
363+
"how long they've been coding, or wants to see their CodeTime stats. " +
364+
"Supports filtering by project name and showing a breakdown of time across all projects.",
365+
args: {
366+
project: tool.schema.string().optional().describe(
367+
"Filter by project name. Use 'current' to auto-detect the current project. " +
368+
"Omit to show total time across all projects.",
369+
),
370+
breakdown: tool.schema.boolean().optional().describe(
371+
"When true, show a breakdown of coding time across all projects today.",
372+
),
373+
},
374+
async execute(args) {
357375
if (!_token) {
358376
return "CodeTime is not configured. Set CODETIME_TOKEN environment variable to enable tracking. Get your token from https://codetime.dev/dashboard/settings";
359377
}
360378

361379
try {
380+
// Breakdown mode: show all projects ranked by time
381+
if (args.breakdown) {
382+
const projects = await getTopProjects(_token);
383+
if (projects === null || projects.length === 0) {
384+
return "No project data available for today.";
385+
}
386+
387+
// Calculate total
388+
const totalMinutes = projects.reduce(
389+
(sum, p) => sum + p.minutes,
390+
0,
391+
);
392+
393+
// Find the longest project name for alignment
394+
const maxNameLen = Math.max(
395+
...projects.map((p) => p.field.length),
396+
"Total".length,
397+
);
398+
399+
const lines = ["Today's coding time by project:", ""];
400+
for (const p of projects) {
401+
const name = p.field.padEnd(maxNameLen + 2);
402+
lines.push(` ${name}${formatMinutes(p.minutes)}`);
403+
}
404+
lines.push(` ${"─".repeat(maxNameLen + 2 + 8)}`);
405+
lines.push(
406+
` ${"Total".padEnd(maxNameLen + 2)}${formatMinutes(totalMinutes)}`,
407+
);
408+
409+
return lines.join("\n");
410+
}
411+
412+
// Project-specific mode
413+
const projectName =
414+
args.project === "current" ? _projectName : args.project;
415+
416+
if (projectName) {
417+
// Fetch both project-specific and total in parallel
418+
const [projectMins, totalMins] = await Promise.all([
419+
getProjectMinutes(_token, projectName),
420+
getTodayMinutes(_token),
421+
]);
422+
423+
if (projectMins === null) {
424+
return `Failed to fetch coding time for project "${projectName}" from CodeTime API.`;
425+
}
426+
427+
const projectFormatted = formatMinutes(projectMins);
428+
const displayName = args.project === "current"
429+
? _projectName
430+
: projectName;
431+
432+
if (totalMins !== null) {
433+
const totalFormatted = formatMinutes(totalMins);
434+
return `Today's coding time for ${displayName}: ${projectFormatted} (Total across all projects: ${totalFormatted})`;
435+
}
436+
return `Today's coding time for ${displayName}: ${projectFormatted}`;
437+
}
438+
439+
// Default: total coding time (original behavior)
362440
const minutes = await getTodayMinutes(_token);
363441
if (minutes === null) {
364442
return "Failed to fetch coding time from CodeTime API.";

0 commit comments

Comments
 (0)