clocktopus 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -0
- package/dist/clockify.js +188 -0
- package/dist/config/clockify.js +7 -0
- package/dist/dashboard/routes/clockify.js +25 -0
- package/dist/dashboard/routes/data.js +61 -0
- package/dist/dashboard/routes/google.js +49 -0
- package/dist/dashboard/routes/jira.js +81 -0
- package/dist/dashboard/routes/monitor.js +45 -0
- package/dist/dashboard/routes/status.js +84 -0
- package/dist/dashboard/routes/timer.js +60 -0
- package/dist/dashboard/server.js +24 -0
- package/dist/dashboard/views.js +843 -0
- package/dist/index.js +277 -0
- package/dist/lib/atlassian.js +93 -0
- package/dist/lib/credentials.js +10 -0
- package/dist/lib/db.js +189 -0
- package/dist/lib/google.js +57 -0
- package/dist/lib/http-client.js +20 -0
- package/dist/lib/jira.js +82 -0
- package/dist/lib/monitors/display-monitor-factory.js +10 -0
- package/dist/lib/monitors/display-monitor-macos.js +67 -0
- package/dist/lib/monitors/idle-monitor.js +33 -0
- package/dist/lib/platform-events.js +37 -0
- package/dist/lib/timer-controller.js +56 -0
- package/dist/proxy/src/index.js +118 -0
- package/dist/scripts/db-cleanup.js +4 -0
- package/dist/scripts/google-auth.js +31 -0
- package/dist/scripts/log-calendar-events.js +141 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Clocktopus
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/logo.png" alt="Clocktopus Logo" width="300px" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
## About
|
|
8
|
+
|
|
9
|
+
Clocktopus is a powerful command-line interface tool designed to streamline your time tracking with platforms like Clockify and Jira. It offers a suite of features to automate and simplify the process of logging your work, ensuring accuracy and efficiency.
|
|
10
|
+
|
|
11
|
+
### Key Features
|
|
12
|
+
|
|
13
|
+
- **Automated Idle Monitoring:** Automatically stops and restarts timers based on your system's idle activity.
|
|
14
|
+
- **Jira Integration:** Seamlessly link your time entries to Jira tickets, fetching and prepending ticket titles to descriptions.
|
|
15
|
+
- **Google Calendar Integration:** Log your Google Calendar events directly as Clockify time entries, with intelligent project caching for recurring events.
|
|
16
|
+
- **Local Project Filtering:** Curate a personalized list of projects for quick selection, reducing clutter.
|
|
17
|
+
- **Session Management:** Start, stop, and check the status of your time entries directly from the terminal.
|
|
18
|
+
- **Database Cleanup:** Easily manage and clean up old session logs from the local SQLite database.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
To get started with the Clocktopus CLI, follow these steps:
|
|
23
|
+
|
|
24
|
+
1. **Clone the repository:**
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone <repository_url>
|
|
28
|
+
cd clocktopus
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
2. **Install dependencies:**
|
|
32
|
+
```bash
|
|
33
|
+
bun install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
### Environment Variables
|
|
39
|
+
|
|
40
|
+
Create a `.env` file in the `data/` directory with the following variables:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
CLOCKIFY_API_KEY="your_clockify_api_key_here"
|
|
44
|
+
ATLASSIAN_CLIENT_ID="your_atlassian_oauth_client_id"
|
|
45
|
+
ATLASSIAN_CLIENT_SECRET="your_atlassian_oauth_client_secret"
|
|
46
|
+
GOOGLE_CLIENT_ID="google_client_id"
|
|
47
|
+
GOOGLE_CLIENT_SECRET="google_client_secret"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You can get your Clockify API key from [Manage API Keys](https://app.clockify.me/manage-api-keys).
|
|
51
|
+
|
|
52
|
+
### Jira Integration (Atlassian OAuth)
|
|
53
|
+
|
|
54
|
+
Clocktopus uses Atlassian OAuth 2.0 so users can connect their Jira account with a single click from the dashboard instead of manually copying API tokens.
|
|
55
|
+
|
|
56
|
+
#### Setting up the Atlassian OAuth App
|
|
57
|
+
|
|
58
|
+
1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/) and create a new **OAuth 2.0 (3LO)** app
|
|
59
|
+
2. Under **Authorization**, set the callback URL to:
|
|
60
|
+
```
|
|
61
|
+
http://localhost:4001/api/jira/callback
|
|
62
|
+
```
|
|
63
|
+
3. Under **Permissions**, add the following scopes:
|
|
64
|
+
- **Jira API**: `read:jira-work`, `write:jira-work`
|
|
65
|
+
- **User identity API**: `read:me`
|
|
66
|
+
4. Copy the **Client ID** and **Client Secret** from the app's **Settings** page
|
|
67
|
+
5. Add them to your `.env` file as `ATLASSIAN_CLIENT_ID` and `ATLASSIAN_CLIENT_SECRET`
|
|
68
|
+
6. Start the dashboard (`bun run dashboard`) and click **Connect Atlassian**
|
|
69
|
+
|
|
70
|
+
> **Fallback:** If you prefer using an API token instead of OAuth, you can expand the "or use API token" section on the dashboard and enter your credentials manually. For this, set the following in `.env`:
|
|
71
|
+
>
|
|
72
|
+
> ```
|
|
73
|
+
> ATLASSIAN_URL="https://your_org.atlassian.net/rest/api/3"
|
|
74
|
+
> ATLASSIAN_API_TOKEN="your_atlassian_api_token_here"
|
|
75
|
+
> ATLASSIAN_EMAIL="username@example.com"
|
|
76
|
+
> ```
|
|
77
|
+
|
|
78
|
+
### Local Projects Filtering
|
|
79
|
+
|
|
80
|
+
The CLI allows you to filter the projects displayed when starting a new time entry. On the first run of the `clock start` command, the application will fetch all your Clockify projects and populate `data/local-projects.json` with their IDs and names. You can then edit this file to keep only the projects you frequently work on.
|
|
81
|
+
|
|
82
|
+
Example `data/local-projects.json`:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
[
|
|
86
|
+
{
|
|
87
|
+
"id": "671b783fbd91bc5e5ddcb944",
|
|
88
|
+
"name": "2024 Project Management Traineeship"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"id": "another_project_id",
|
|
92
|
+
"name": "Another Project Name"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Remove any project objects (both `id` and `name`) that you don't want to appear in the project selection list.
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
### Build the application
|
|
102
|
+
|
|
103
|
+
Before running, you need to compile the TypeScript code:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
bun run build
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Run the application
|
|
110
|
+
|
|
111
|
+
Once built, you can run the CLI commands using the following commands:
|
|
112
|
+
|
|
113
|
+
- **Monitor idle state and auto-manage timers:**
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
bun run monitor
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This command will monitor your system's idle time and automatically manage your Clockify timer:
|
|
120
|
+
- If you are idle for more than 5 minutes, the currently running timer will be stopped.
|
|
121
|
+
- When you become active again (move the mouse, press a key, etc.), if your last session was auto-completed due to idleness, a new timer will automatically be created for the last used project.
|
|
122
|
+
- All session events (start, stop, auto-complete, resume) are logged locally in the SQLite database, including project and description.
|
|
123
|
+
|
|
124
|
+
This ensures your time tracking is accurate even if you step away from your computer or forget to manually stop and restart your timer.
|
|
125
|
+
|
|
126
|
+
> Note: This ensures your time tracking is accurate even if you step away from your computer or forget to manually stop and restart your timer.
|
|
127
|
+
> If you do not want the background process to check your idle state, you can skip this.
|
|
128
|
+
|
|
129
|
+
- **Start a new time entry:**
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
bun run clock start "Task description"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
This will prompt you to select a project from your curated list.
|
|
136
|
+
|
|
137
|
+
- **Start a new time entry with a Jira ticket:**
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
bun run clock start -j TICKET-123
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
When you provide a Jira ticket number with the `-j` flag, the tool will automatically fetch the ticket's title from Jira and prepend it to your time entry description. For example, if the title of `TICKET-123` is "Fix the login button", the description will be saved as `TICKET-123 Fix the login button`. If you also provide a description, it will be appended after the Jira title.
|
|
144
|
+
|
|
145
|
+
- **Stop the currently running time entry:**
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bun run clock stop
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- **Check the status of the current timer:**
|
|
152
|
+
```bash
|
|
153
|
+
bun run clock status
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Manage Monitor
|
|
157
|
+
|
|
158
|
+
- **Restart the monitor process (after code changes):**
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
bun run monitor:restart
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Use this command to restart the monitor process, for example after making code changes or updating dependencies. This ensures the monitor is running the latest version of your code.
|
|
165
|
+
|
|
166
|
+
- **Stop the monitor process:**
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
bun run monitor:stop
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This command will stop the monitor process if it is running in the background. Use this when you want to fully halt all automatic idle monitoring and timer management.
|
|
173
|
+
|
|
174
|
+
- **Show monitor logs:**
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
bun run monitor:logs
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
This command will display logs related to the monitor process. Use it to review idle/active transitions, timer events, and session details that have been recorded while the monitor was running. This is useful for troubleshooting, auditing, or reviewing your time tracking history.
|
|
181
|
+
|
|
182
|
+
### Database Cleanup
|
|
183
|
+
|
|
184
|
+
To delete old session logs from the local SQLite database, use the `db:cleanup` command:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
bun run db:cleanup <older-than-number-in-days>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- `<older-than-number-in-days>` (Optional): Specifies the number of days. Session logs older than this number of days will be deleted. If not provided, logs older than 5 days will be deleted by default.
|
|
191
|
+
|
|
192
|
+
Examples:
|
|
193
|
+
|
|
194
|
+
- Delete logs older than 5 days (default):
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bun run db:cleanup
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- Delete logs older than 5 days:
|
|
201
|
+
```bash
|
|
202
|
+
bun run db:cleanup 5
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Google Calendar Integration
|
|
206
|
+
|
|
207
|
+
This tool can log your Google Calendar events as time entries in Clockify. This is particularly useful for automatically tracking time spent in meetings or other scheduled events.
|
|
208
|
+
|
|
209
|
+
#### 1. Google Authentication
|
|
210
|
+
|
|
211
|
+
Before you can log calendar events, you need to authenticate with your Google account. This will grant the tool read-only access to your Google Calendar.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
bun run google-auth
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Follow the prompts in your browser to complete the authentication process. A token will be stored locally to maintain your session.
|
|
218
|
+
|
|
219
|
+
#### 2. Log Calendar Events
|
|
220
|
+
|
|
221
|
+
Once authenticated, you can log events for a specific date range:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
bun run log-calendar -s <start-date> -e <end-date>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- `<start-date>`: The start date for fetching calendar events (e.g., `2025-07-21`).
|
|
228
|
+
- `<end-date>`: The end date for fetching calendar events (e.g., `2025-07-22`).
|
|
229
|
+
|
|
230
|
+
You can also log events for today using the `-t` or `--today` flag:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
bun run log-calendar -t
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
For each calendar event, the tool will prompt you to select a Clockify project. Your selection will be cached based on the event's summary (name), so if you have recurring events with the same name, you will only be asked once for the project. If you provide a `project-id` using the `-p` flag, all events will be logged to that project without prompting.
|
|
237
|
+
|
|
238
|
+
Example:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
bun run log-calendar -s 2025-07-21 -e 2025-07-22
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Or, to log all events to a specific project:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
bun run log-calendar -s 2025-07-21 -e 2025-07-22 -p your_clockify_project_id
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Troubleshooting
|
|
251
|
+
|
|
252
|
+
### No notifications on macOS
|
|
253
|
+
|
|
254
|
+
If you are not receiving notifications on macOS, you may need to adjust your system settings.
|
|
255
|
+
|
|
256
|
+
- **Check System Settings for Notifications:**
|
|
257
|
+
- Go to **System Settings > Notifications**.
|
|
258
|
+
- Look for **Terminal** (or your specific terminal application if you use another one like iTerm).
|
|
259
|
+
- Make sure that **Allow Notifications** is turned on for it.
|
|
260
|
+
- If you see an entry for **Node**, ensure it also has permissions.
|
|
261
|
+
|
|
262
|
+
Often, the first time a script tries to send a notification, macOS will ask for permission. If this was accidentally denied, you won't see any notifications.
|
|
263
|
+
|
|
264
|
+
## Linux Requirements
|
|
265
|
+
|
|
266
|
+
X server development package and pkg-config are required to run `desktop-idle` package:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
apt install libxss-dev pkg-config build-essential
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Zsh Alias
|
|
273
|
+
|
|
274
|
+
If you super lazy just like me, then you can add aliases for different actions. Here is what I use:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
# Clocktopus
|
|
278
|
+
CLOCKTOPUS_PATH="$HOME/Projects/Personal/clocktopus"
|
|
279
|
+
|
|
280
|
+
clocktopus() {
|
|
281
|
+
cd "$CLOCKTOPUS_PATH" || return
|
|
282
|
+
bun run "$@"
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
alias cbuild="clocktopus build"
|
|
286
|
+
alias cstart="clocktopus clock start"
|
|
287
|
+
alias cstop="clocktopus clock stop"
|
|
288
|
+
alias mstart="clocktopus monitor"
|
|
289
|
+
alias mstop="clocktopus monitor:stop"
|
|
290
|
+
alias mrestart="clocktopus monitor:restart"
|
|
291
|
+
alias mstatus="clocktopus monitor:status"
|
|
292
|
+
alias mlogs="clocktopus monitor:logs"
|
|
293
|
+
alias cgcalauth="clocktopus google-auth"
|
|
294
|
+
alias cgcal="clocktopus log-calendar"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Copy the above code in `.zshrc` file, change `CLOCKTOPUS_PATH` based on your path and save it. Then source the file using `source ~/.zshrc`.
|
package/dist/clockify.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { HttpClient } from './lib/http-client.js';
|
|
2
|
+
import { logSessionStart } from './lib/db.js';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { NotificationCenter } from 'node-notifier';
|
|
5
|
+
import { getJiraTicket } from './lib/jira.js';
|
|
6
|
+
export class Clockify {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.httpClient = new HttpClient().getClient();
|
|
9
|
+
this.notifier = new NotificationCenter();
|
|
10
|
+
}
|
|
11
|
+
sendNotification(title, message, actions, callback) {
|
|
12
|
+
this.notifier.notify({
|
|
13
|
+
title,
|
|
14
|
+
message,
|
|
15
|
+
sound: true,
|
|
16
|
+
wait: true,
|
|
17
|
+
actions,
|
|
18
|
+
}, callback ??
|
|
19
|
+
((err) => {
|
|
20
|
+
if (err)
|
|
21
|
+
console.error('Notification error:', err);
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
async getUser() {
|
|
25
|
+
try {
|
|
26
|
+
const response = await this.httpClient.get('/user');
|
|
27
|
+
return response.data;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof Error) {
|
|
31
|
+
console.error('[clockify] Could not connect to Clockify. Please check your API key.', error.message);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.error('[clockify] An unknown error occurred.');
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async getProjects(workspaceId) {
|
|
40
|
+
try {
|
|
41
|
+
let allProjects = [];
|
|
42
|
+
let page = 1;
|
|
43
|
+
const pageSize = 50;
|
|
44
|
+
let hasMore = true;
|
|
45
|
+
while (hasMore) {
|
|
46
|
+
const response = await this.httpClient.get(`/workspaces/${workspaceId}/projects`, {
|
|
47
|
+
params: {
|
|
48
|
+
page: page,
|
|
49
|
+
'page-size': pageSize,
|
|
50
|
+
archived: false,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
if (response.data.length > 0) {
|
|
54
|
+
allProjects = allProjects.concat(response.data);
|
|
55
|
+
page++;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
hasMore = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return allProjects;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof Error) {
|
|
65
|
+
console.error('Error fetching projects:', error.message);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.error('Error fetching projects: An unknown error occurred.');
|
|
69
|
+
}
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async getProjectById(workspaceId, projectId) {
|
|
74
|
+
try {
|
|
75
|
+
const response = await this.httpClient.get(`/workspaces/${workspaceId}/projects/${projectId}`);
|
|
76
|
+
return response.data;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof Error) {
|
|
80
|
+
console.error('Error fetching project:', error.message);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.error('Error fetching project: An unknown error occurred.');
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async startTimer(workspaceId, projectId, description = 'Working on a task...', jiraTicket) {
|
|
89
|
+
try {
|
|
90
|
+
const user = await this.getUser();
|
|
91
|
+
if (!user) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
let finalDescription = description;
|
|
95
|
+
if (jiraTicket) {
|
|
96
|
+
const ticket = await getJiraTicket(jiraTicket);
|
|
97
|
+
if (ticket) {
|
|
98
|
+
finalDescription = `${jiraTicket} ${ticket.fields.summary}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const startedAt = new Date().toISOString();
|
|
102
|
+
const sessionId = uuidv4();
|
|
103
|
+
const response = await this.httpClient.post(`/workspaces/${workspaceId}/time-entries`, {
|
|
104
|
+
projectId: projectId,
|
|
105
|
+
description: finalDescription,
|
|
106
|
+
start: startedAt,
|
|
107
|
+
});
|
|
108
|
+
// Log session to SQLite
|
|
109
|
+
logSessionStart(sessionId, projectId, finalDescription, startedAt, jiraTicket);
|
|
110
|
+
const project = await this.getProjectById(workspaceId, projectId);
|
|
111
|
+
this.sendNotification(`Timer started for ${project ? project.name : 'a project'}`, finalDescription, ['Stop'], (err, response, metadata) => {
|
|
112
|
+
if (err) {
|
|
113
|
+
console.error(err);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (metadata.activationValue === 'Stop') {
|
|
117
|
+
this.stopTimer(workspaceId, user.id);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return response.data;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof Error) {
|
|
124
|
+
console.error('Error starting timer:', error.message);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
console.error('Error starting timer: An unknown error occurred.');
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async stopTimer(workspaceId, userId) {
|
|
133
|
+
try {
|
|
134
|
+
const response = await this.httpClient.patch(`/workspaces/${workspaceId}/user/${userId}/time-entries`, {
|
|
135
|
+
end: new Date().toISOString(),
|
|
136
|
+
});
|
|
137
|
+
this.sendNotification('Timer stopped', 'Your timer has been stopped.');
|
|
138
|
+
return response.data;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof Error) {
|
|
142
|
+
console.error('Error stopping timer:', error.message);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
console.error('Error stopping timer: An unknown error occurred.');
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async getActiveTimer(workspaceId, userId) {
|
|
151
|
+
try {
|
|
152
|
+
const response = await this.httpClient.get(`/workspaces/${workspaceId}/user/${userId}/time-entries?in-progress=true`);
|
|
153
|
+
return response.data[0];
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof Error) {
|
|
157
|
+
console.error('Error fetching active timer:', error.message);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.error('Error fetching active timer: An unknown error occurred.');
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async logTime(workspaceId, projectId, start, end, description) {
|
|
166
|
+
if (!projectId) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const response = await this.httpClient.post(`/workspaces/${workspaceId}/time-entries`, {
|
|
171
|
+
projectId: projectId,
|
|
172
|
+
start: start,
|
|
173
|
+
end: end,
|
|
174
|
+
description: description,
|
|
175
|
+
});
|
|
176
|
+
return response.data;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error instanceof Error) {
|
|
180
|
+
console.error('Error logging time:', error.message);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
console.error('Error logging time: An unknown error occurred.');
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { saveCredential } from '../../lib/credentials.js';
|
|
4
|
+
const clockifyRoutes = new Hono();
|
|
5
|
+
clockifyRoutes.post('/clockify', async (c) => {
|
|
6
|
+
const { apiKey } = await c.req.json();
|
|
7
|
+
if (!apiKey) {
|
|
8
|
+
return c.json({ ok: false, error: 'API key is required.' }, 400);
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const res = await axios.get('https://api.clockify.me/api/v1/user', {
|
|
12
|
+
headers: { 'X-Api-Key': apiKey },
|
|
13
|
+
timeout: 5000,
|
|
14
|
+
});
|
|
15
|
+
if (res.status === 200) {
|
|
16
|
+
saveCredential('CLOCKIFY_API_KEY', apiKey);
|
|
17
|
+
return c.json({ ok: true, user: res.data.name });
|
|
18
|
+
}
|
|
19
|
+
return c.json({ ok: false, error: 'Invalid API key.' });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return c.json({ ok: false, error: 'Could not validate API key with Clockify.' });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
export default clockifyRoutes;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { getRecentSessions, getSessionCount, getActiveProjects, getAllProjects, upsertProjects, toggleProjectActive, } from '../../lib/db.js';
|
|
3
|
+
import { Clockify } from '../../clockify.js';
|
|
4
|
+
const dataRoutes = new Hono();
|
|
5
|
+
// Active projects for timer dropdown
|
|
6
|
+
dataRoutes.get('/projects', (c) => {
|
|
7
|
+
const projects = getActiveProjects();
|
|
8
|
+
return c.json(projects);
|
|
9
|
+
});
|
|
10
|
+
// All projects for settings management
|
|
11
|
+
dataRoutes.get('/projects/all', (c) => {
|
|
12
|
+
const projects = getAllProjects();
|
|
13
|
+
return c.json(projects);
|
|
14
|
+
});
|
|
15
|
+
// Fetch projects from Clockify and save to DB
|
|
16
|
+
dataRoutes.post('/projects/fetch', async (c) => {
|
|
17
|
+
try {
|
|
18
|
+
const clockify = new Clockify();
|
|
19
|
+
const user = await clockify.getUser();
|
|
20
|
+
if (!user)
|
|
21
|
+
return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
|
|
22
|
+
const projects = await clockify.getProjects(user.defaultWorkspace);
|
|
23
|
+
if (projects.length === 0)
|
|
24
|
+
return c.json({ ok: false, error: 'No projects found.' }, 404);
|
|
25
|
+
upsertProjects(projects);
|
|
26
|
+
return c.json({ ok: true, count: projects.length });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return c.json({ ok: false, error: 'Failed to fetch projects.' }, 500);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
// Toggle project active status
|
|
33
|
+
dataRoutes.post('/projects/toggle', async (c) => {
|
|
34
|
+
const { id, active } = await c.req.json();
|
|
35
|
+
if (!id)
|
|
36
|
+
return c.json({ ok: false, error: 'Project ID required.' }, 400);
|
|
37
|
+
toggleProjectActive(id, active);
|
|
38
|
+
return c.json({ ok: true });
|
|
39
|
+
});
|
|
40
|
+
// Sessions with pagination
|
|
41
|
+
dataRoutes.get('/sessions', (c) => {
|
|
42
|
+
const page = Math.max(1, parseInt(c.req.query('page') || '1', 10));
|
|
43
|
+
const limit = Math.min(50, Math.max(1, parseInt(c.req.query('limit') || '10', 10)));
|
|
44
|
+
const offset = (page - 1) * limit;
|
|
45
|
+
const sessions = getRecentSessions(limit, offset);
|
|
46
|
+
const total = getSessionCount();
|
|
47
|
+
const allProjects = getAllProjects();
|
|
48
|
+
const projectMap = new Map(allProjects.map((p) => [p.id, p.name]));
|
|
49
|
+
const enriched = sessions.map((s) => ({
|
|
50
|
+
...s,
|
|
51
|
+
projectName: projectMap.get(s.projectId) ?? 'Unknown',
|
|
52
|
+
}));
|
|
53
|
+
return c.json({
|
|
54
|
+
data: enriched,
|
|
55
|
+
page,
|
|
56
|
+
limit,
|
|
57
|
+
total,
|
|
58
|
+
totalPages: Math.ceil(total / limit),
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
export default dataRoutes;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { google } from 'googleapis';
|
|
3
|
+
import { getAuthenticatedClient, getAuthUrl, exchangeGoogleCode } from '../../lib/google.js';
|
|
4
|
+
import { storeToken } from '../../lib/db.js';
|
|
5
|
+
import { saveCredential } from '../../lib/credentials.js';
|
|
6
|
+
const DASHBOARD_REDIRECT_URI = 'http://localhost:4001/api/google/callback';
|
|
7
|
+
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/userinfo.email'];
|
|
8
|
+
const googleRoutes = new Hono();
|
|
9
|
+
googleRoutes.get('/google/connect', async (c) => {
|
|
10
|
+
try {
|
|
11
|
+
const url = await getAuthUrl(DASHBOARD_REDIRECT_URI, SCOPES);
|
|
12
|
+
return c.redirect(url);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return c.json({ ok: false, error: 'Failed to generate Google auth URL.' }, 500);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
googleRoutes.get('/google/auth-url', async (c) => {
|
|
19
|
+
try {
|
|
20
|
+
const url = await getAuthUrl(DASHBOARD_REDIRECT_URI, SCOPES);
|
|
21
|
+
return c.json({ url });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return c.json({ ok: false, error: 'Failed to generate Google auth URL.' }, 500);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
googleRoutes.get('/google/callback', async (c) => {
|
|
28
|
+
const code = c.req.query('code');
|
|
29
|
+
if (!code) {
|
|
30
|
+
return c.text('Missing authorization code.', 400);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const tokens = await exchangeGoogleCode(code, DASHBOARD_REDIRECT_URI);
|
|
34
|
+
storeToken(tokens);
|
|
35
|
+
// Fetch and store the user's email
|
|
36
|
+
const oAuth2Client = getAuthenticatedClient(DASHBOARD_REDIRECT_URI);
|
|
37
|
+
oAuth2Client.setCredentials(tokens);
|
|
38
|
+
const oauth2 = google.oauth2({ version: 'v2', auth: oAuth2Client });
|
|
39
|
+
const { data } = await oauth2.userinfo.get();
|
|
40
|
+
if (data.email) {
|
|
41
|
+
saveCredential('GOOGLE_ACCOUNT_EMAIL', data.email);
|
|
42
|
+
}
|
|
43
|
+
return c.redirect('/?google=connected');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return c.text('Failed to exchange authorization code for tokens.', 500);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
export default googleRoutes;
|