pacatui 0.1.11 → 0.1.14
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/package.json +2 -1
- package/src/components/Dashboard.tsx +176 -84
- package/src/index.tsx +8 -0
- package/src/menubar/action.ts +80 -0
- package/src/menubar/db-lite.ts +95 -0
- package/src/menubar/index.ts +212 -0
- package/src/menubar/plugin.ts +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pacatui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "A simple tui app for task, timer and invoicing for projects.",
|
|
5
5
|
"module": "src/index.tsx",
|
|
6
6
|
"type": "module",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"@prisma/adapter-libsql": "^7.2.0",
|
|
67
67
|
"@prisma/client": "^7.2.0",
|
|
68
68
|
"better-sqlite3": "^12.6.0",
|
|
69
|
+
"paca4": ".",
|
|
69
70
|
"prisma": "^7.2.0",
|
|
70
71
|
"react": "^19.2.3",
|
|
71
72
|
"stripe": "^20.2.0"
|
|
@@ -109,29 +109,53 @@ function WeeklyTimeChart({
|
|
|
109
109
|
}) {
|
|
110
110
|
const colors = theme.colors;
|
|
111
111
|
const chartColors = theme.projectColors;
|
|
112
|
-
|
|
113
|
-
const
|
|
112
|
+
// Braille chars filled bottom-up (both columns): ⠀ ⣀ ⣤ ⣶ ⣿
|
|
113
|
+
const brailleBlocks = [
|
|
114
|
+
String.fromCodePoint(0x2800), // 0/4 empty
|
|
115
|
+
String.fromCodePoint(0x28c0), // 1/4 dots 7,8
|
|
116
|
+
String.fromCodePoint(0x28e4), // 2/4 dots 3,6,7,8
|
|
117
|
+
String.fromCodePoint(0x28f6), // 3/4 dots 2,3,5,6,7,8
|
|
118
|
+
String.fromCodePoint(0x28ff), // 4/4 all dots
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
// Available width after parent padding (2 per side)
|
|
122
|
+
const availableWidth = Math.max(width - 4, 16);
|
|
123
|
+
const chartHeight = 8; // character rows for bar area (8 × 4 = 32 levels)
|
|
124
|
+
const gap = 1;
|
|
125
|
+
const minBarWidth = 2;
|
|
126
|
+
const maxBarWidth = 10;
|
|
127
|
+
const maxWeeks = 25;
|
|
128
|
+
|
|
129
|
+
// Determine how many bars fit
|
|
130
|
+
const maxBars = Math.min(
|
|
131
|
+
data.length,
|
|
132
|
+
maxWeeks,
|
|
133
|
+
Math.floor((availableWidth + gap) / (minBarWidth + gap)),
|
|
134
|
+
);
|
|
114
135
|
const recentData = data.slice(-maxBars);
|
|
115
136
|
|
|
116
137
|
if (recentData.length === 0) {
|
|
117
138
|
return (
|
|
118
139
|
<box style={{ flexDirection: "column", gap: 1, width: "100%" }}>
|
|
119
|
-
<text fg={colors.textSecondary}>
|
|
140
|
+
<text fg={colors.textSecondary}>
|
|
141
|
+
No time entries in the last 6 months
|
|
142
|
+
</text>
|
|
120
143
|
</box>
|
|
121
144
|
);
|
|
122
145
|
}
|
|
123
146
|
|
|
124
|
-
//
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
// Dynamic bar width to fill available space, capped
|
|
148
|
+
const barWidth = Math.min(
|
|
149
|
+
maxBarWidth,
|
|
150
|
+
Math.max(
|
|
151
|
+
minBarWidth,
|
|
152
|
+
Math.floor(
|
|
153
|
+
(availableWidth - (recentData.length - 1) * gap) / recentData.length,
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
133
157
|
|
|
134
|
-
// Collect unique projects for legend (sorted by total time
|
|
158
|
+
// Collect unique projects for legend (sorted by total time)
|
|
135
159
|
const projectTotals = new Map<
|
|
136
160
|
string,
|
|
137
161
|
{ name: string; color: string; totalMs: number }
|
|
@@ -150,18 +174,16 @@ function WeeklyTimeChart({
|
|
|
150
174
|
}
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
|
-
// Sort projects by total time (descending) for consistent stacking order
|
|
154
177
|
const sortedProjects = Array.from(projectTotals.entries()).sort(
|
|
155
178
|
(a, b) => b[1].totalMs - a[1].totalMs,
|
|
156
179
|
);
|
|
157
180
|
|
|
158
|
-
// Assign dynamic colors to each project based on their sorted position
|
|
159
181
|
const projectColorMap = new Map<string, string>();
|
|
160
182
|
sortedProjects.forEach(([projectId], index) => {
|
|
161
183
|
projectColorMap.set(projectId, chartColors[index % chartColors.length]!);
|
|
162
184
|
});
|
|
163
185
|
|
|
164
|
-
// Pre-sort each week's projects by
|
|
186
|
+
// Pre-sort each week's projects by global order for consistent stacking
|
|
165
187
|
const weekProjectsSorted = recentData.map((week) =>
|
|
166
188
|
[...week.projects].sort((a, b) => {
|
|
167
189
|
const aIdx = sortedProjects.findIndex(([id]) => id === a.projectId);
|
|
@@ -170,109 +192,179 @@ function WeeklyTimeChart({
|
|
|
170
192
|
}),
|
|
171
193
|
);
|
|
172
194
|
|
|
173
|
-
|
|
174
|
-
const barHeights = recentData.map((week) =>
|
|
175
|
-
Math.round((week.totalMs / maxMs) * barHeight),
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
// Build bars row by row (from top to bottom, +1 row for labels on tallest bars)
|
|
179
|
-
const rows: ReactNode[] = [];
|
|
180
|
-
for (let row = barHeight; row >= 0; row--) {
|
|
181
|
-
const rowParts: ReactNode[] = [];
|
|
182
|
-
|
|
183
|
-
for (let i = 0; i < recentData.length; i++) {
|
|
184
|
-
const week = recentData[i]!;
|
|
185
|
-
const barTotalRows = barHeights[i]!;
|
|
195
|
+
const maxMs = Math.max(...recentData.map((d) => d.totalMs), 1);
|
|
186
196
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
197
|
+
// Build each bar column: array of {char, color} from row 0 (bottom) to top
|
|
198
|
+
const barColumns = recentData.map((week, weekIdx) => {
|
|
199
|
+
const totalFourths = Math.round(
|
|
200
|
+
(week.totalMs / maxMs) * chartHeight * 4,
|
|
201
|
+
);
|
|
202
|
+
const weekProjects = weekProjectsSorted[weekIdx]!;
|
|
203
|
+
const cells: Array<{ char: string; color: string }> = [];
|
|
192
204
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
for (let row = 0; row < chartHeight; row++) {
|
|
206
|
+
const rowBottom = row * 4;
|
|
207
|
+
const fill = Math.min(Math.max(totalFourths - rowBottom, 0), 4);
|
|
196
208
|
|
|
209
|
+
if (fill === 0) {
|
|
210
|
+
cells.push({ char: " ", color: colors.textMuted });
|
|
211
|
+
} else {
|
|
212
|
+
// Find project color at midpoint of filled portion of this row
|
|
213
|
+
const midFourths = rowBottom + fill / 2;
|
|
214
|
+
const midFraction =
|
|
215
|
+
totalFourths > 0 ? midFourths / totalFourths : 0;
|
|
216
|
+
let cumFraction = 0;
|
|
217
|
+
let color = chartColors[0]!;
|
|
197
218
|
for (const proj of weekProjects) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
projectColor =
|
|
219
|
+
cumFraction += proj.ms / week.totalMs;
|
|
220
|
+
if (midFraction <= cumFraction) {
|
|
221
|
+
color =
|
|
202
222
|
projectColorMap.get(proj.projectId) || chartColors[0]!;
|
|
203
223
|
break;
|
|
204
224
|
}
|
|
205
225
|
}
|
|
226
|
+
cells.push({ char: brailleBlocks[fill]!, color });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return cells;
|
|
230
|
+
});
|
|
206
231
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
232
|
+
// Render a label row where labels can extend beyond bar width.
|
|
233
|
+
// Labels are centered on their bar position and skipped if they'd overlap.
|
|
234
|
+
function buildLabelRow(
|
|
235
|
+
labels: string[],
|
|
236
|
+
color: string,
|
|
237
|
+
key: string,
|
|
238
|
+
): ReactNode {
|
|
239
|
+
const totalWidth =
|
|
240
|
+
labels.length * barWidth + (labels.length - 1) * gap;
|
|
241
|
+
const chars = new Array(totalWidth).fill(" ");
|
|
242
|
+
|
|
243
|
+
let lastEnd = -2;
|
|
244
|
+
for (let i = 0; i < labels.length; i++) {
|
|
245
|
+
const label = labels[i]!;
|
|
246
|
+
if (!label) continue;
|
|
247
|
+
|
|
248
|
+
const barStart = i * (barWidth + gap);
|
|
249
|
+
const barCenter = barStart + Math.floor(barWidth / 2);
|
|
250
|
+
const labelStart = Math.max(
|
|
251
|
+
0,
|
|
252
|
+
barCenter - Math.floor(label.length / 2),
|
|
253
|
+
);
|
|
254
|
+
const labelEnd = labelStart + label.length;
|
|
255
|
+
|
|
256
|
+
if (labelStart > lastEnd && labelEnd <= totalWidth) {
|
|
257
|
+
for (let j = 0; j < label.length; j++) {
|
|
258
|
+
chars[labelStart + j] = label[j];
|
|
259
|
+
}
|
|
260
|
+
lastEnd = labelEnd;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<text key={key} fg={color}>
|
|
266
|
+
{chars.join("")}
|
|
267
|
+
</text>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Short top labels: rounded hours with no unit
|
|
272
|
+
const topLabels = recentData.map((week) => {
|
|
273
|
+
if (week.totalMs === 0) return "";
|
|
274
|
+
const hours = week.totalMs / 3600000;
|
|
275
|
+
const rounded = Math.round(hours);
|
|
276
|
+
return String(rounded || "<1");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Row where each label sits (0-indexed from bottom, just above bar top)
|
|
280
|
+
const barLabelRows = recentData.map((week) => {
|
|
281
|
+
const totalFourths = Math.round(
|
|
282
|
+
(week.totalMs / maxMs) * chartHeight * 4,
|
|
283
|
+
);
|
|
284
|
+
return Math.min(Math.ceil(totalFourths / 4), chartHeight - 1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const rows: ReactNode[] = [];
|
|
288
|
+
|
|
289
|
+
// Bar rows (top to bottom), with labels embedded on top of each bar
|
|
290
|
+
for (let row = chartHeight - 1; row >= 0; row--) {
|
|
291
|
+
const rowParts: ReactNode[] = [];
|
|
292
|
+
for (let i = 0; i < barColumns.length; i++) {
|
|
293
|
+
const isLast = i === barColumns.length - 1;
|
|
294
|
+
if (row === barLabelRows[i] && topLabels[i]) {
|
|
295
|
+
const label = topLabels[i]!;
|
|
296
|
+
// Use barWidth + gap for centering (absorb trailing gap)
|
|
297
|
+
const slotWidth = isLast ? barWidth : barWidth + gap;
|
|
298
|
+
const padLeft = Math.floor((slotWidth - label.length) / 2);
|
|
217
299
|
const centered =
|
|
218
300
|
" ".repeat(padLeft) +
|
|
219
|
-
|
|
220
|
-
" ".repeat(
|
|
301
|
+
label +
|
|
302
|
+
" ".repeat(Math.max(0, slotWidth - padLeft - label.length));
|
|
221
303
|
rowParts.push(
|
|
222
304
|
<span key={i} fg={colors.textSecondary}>
|
|
223
305
|
{centered}
|
|
224
306
|
</span>,
|
|
225
307
|
);
|
|
226
308
|
} else {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
const cell = barColumns[i]![row]!;
|
|
310
|
+
rowParts.push(
|
|
311
|
+
<span key={i} fg={cell.color}>
|
|
312
|
+
{cell.char.repeat(barWidth)}
|
|
313
|
+
</span>,
|
|
314
|
+
);
|
|
315
|
+
if (!isLast) {
|
|
316
|
+
rowParts.push(
|
|
317
|
+
<span key={`sp-${i}`}>{" ".repeat(gap)}</span>,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
232
320
|
}
|
|
233
321
|
}
|
|
234
|
-
|
|
235
|
-
rows.push(<text key={row}>{rowParts}</text>);
|
|
322
|
+
rows.push(<text key={`row-${row}`}>{rowParts}</text>);
|
|
236
323
|
}
|
|
237
324
|
|
|
238
|
-
//
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
// Build date labels row
|
|
242
|
-
const dateLabelParts: ReactNode[] = [];
|
|
325
|
+
// Baseline
|
|
326
|
+
const baselineParts: ReactNode[] = [];
|
|
243
327
|
for (let i = 0; i < recentData.length; i++) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const centered =
|
|
248
|
-
" ".repeat(padLeft) +
|
|
249
|
-
trimmed +
|
|
250
|
-
" ".repeat(barWidth - padLeft - trimmed.length);
|
|
251
|
-
dateLabelParts.push(
|
|
252
|
-
<span key={i} fg={colors.textSecondary}>
|
|
253
|
-
{centered}
|
|
328
|
+
baselineParts.push(
|
|
329
|
+
<span key={i} fg={colors.borderSubtle}>
|
|
330
|
+
{"─".repeat(barWidth)}
|
|
254
331
|
</span>,
|
|
255
332
|
);
|
|
256
333
|
if (i < recentData.length - 1) {
|
|
257
|
-
|
|
334
|
+
baselineParts.push(
|
|
335
|
+
<span key={`sp-${i}`} fg={colors.borderSubtle}>
|
|
336
|
+
{" ".repeat(gap)}
|
|
337
|
+
</span>,
|
|
338
|
+
);
|
|
258
339
|
}
|
|
259
340
|
}
|
|
341
|
+
rows.push(<text key="baseline">{baselineParts}</text>);
|
|
342
|
+
|
|
343
|
+
// Date labels
|
|
344
|
+
const dateLabels = recentData.map((week) => week.weekLabel);
|
|
345
|
+
rows.push(buildLabelRow(dateLabels, colors.textSecondary, "dates"));
|
|
346
|
+
|
|
347
|
+
const totalMs = recentData.reduce((sum, w) => sum + w.totalMs, 0);
|
|
260
348
|
|
|
261
349
|
return (
|
|
262
|
-
<box style={{ flexDirection: "column",
|
|
263
|
-
{/* Bars (with hour labels at top) */}
|
|
350
|
+
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
264
351
|
{rows}
|
|
265
|
-
{/*
|
|
266
|
-
<text>{dateLabelParts}</text>
|
|
267
|
-
|
|
268
|
-
{/* Legend - show top projects by time */}
|
|
352
|
+
{/* Legend */}
|
|
269
353
|
<box
|
|
270
|
-
style={{
|
|
354
|
+
style={{
|
|
355
|
+
flexDirection: "row",
|
|
356
|
+
gap: 2,
|
|
357
|
+
marginTop: 1,
|
|
358
|
+
flexWrap: "wrap",
|
|
359
|
+
}}
|
|
271
360
|
>
|
|
272
361
|
{sortedProjects.slice(0, 4).map(([id, proj]) => (
|
|
273
362
|
<text key={id}>
|
|
274
363
|
<span fg={projectColorMap.get(id)}>●</span>
|
|
275
|
-
<span fg={colors.textSecondary}>
|
|
364
|
+
<span fg={colors.textSecondary}>
|
|
365
|
+
{" "}
|
|
366
|
+
{proj.name.slice(0, 12)}
|
|
367
|
+
</span>
|
|
276
368
|
</text>
|
|
277
369
|
))}
|
|
278
370
|
<text>
|
package/src/index.tsx
CHANGED
|
@@ -138,6 +138,14 @@ function showSplash(theme: Theme): boolean {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
async function main() {
|
|
141
|
+
// Handle menubar subcommand before TUI setup
|
|
142
|
+
const args = process.argv.slice(2);
|
|
143
|
+
if (args[0] === "menubar") {
|
|
144
|
+
const { menubarCommand } = await import("./menubar/index.ts");
|
|
145
|
+
await menubarCommand(args.slice(1));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
// Get stored theme and show splash with ASCII paca
|
|
142
150
|
const theme = getStoredTheme();
|
|
143
151
|
const splashShown = showSplash(theme);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the content of the action handler script.
|
|
6
|
+
* This script is invoked by SwiftBar when the user clicks a menu item.
|
|
7
|
+
*/
|
|
8
|
+
export function getActionScript(bunPath: string): string {
|
|
9
|
+
const dbPath = join(homedir(), ".paca", "paca.db");
|
|
10
|
+
|
|
11
|
+
return `#!${bunPath}
|
|
12
|
+
// Paca SwiftBar Action Handler — auto-generated, do not edit
|
|
13
|
+
|
|
14
|
+
import Database from "bun:sqlite";
|
|
15
|
+
|
|
16
|
+
const DB_PATH = ${JSON.stringify(dbPath)};
|
|
17
|
+
|
|
18
|
+
function openDb() {
|
|
19
|
+
const db = new Database(DB_PATH);
|
|
20
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
21
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
22
|
+
return db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function askDescription(): Promise<string> {
|
|
26
|
+
try {
|
|
27
|
+
const proc = Bun.spawnSync([
|
|
28
|
+
"osascript", "-e",
|
|
29
|
+
'display dialog "What did you work on?" default answer "" with title "Paca — Stop Timer" buttons {"Cancel", "Save"} default button "Save"',
|
|
30
|
+
]);
|
|
31
|
+
const output = proc.stdout.toString().trim();
|
|
32
|
+
// osascript returns: "button returned:Save, text returned:my description"
|
|
33
|
+
const match = output.match(/text returned:(.*)/);
|
|
34
|
+
return match?.[1]?.trim() ?? "";
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
const action = process.argv[2];
|
|
42
|
+
const id = process.argv[3];
|
|
43
|
+
|
|
44
|
+
if (!action || !id) {
|
|
45
|
+
console.error("Usage: action.ts <start|stop> <id>");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const db = openDb();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (action === "stop") {
|
|
53
|
+
const description = await askDescription();
|
|
54
|
+
db.query(
|
|
55
|
+
\`UPDATE TimeEntry SET endTime = datetime('now'), description = ?, updatedAt = datetime('now') WHERE id = ?\`
|
|
56
|
+
).run(description || null, id);
|
|
57
|
+
} else if (action === "start") {
|
|
58
|
+
// Stop any running timer first
|
|
59
|
+
const running = db.query("SELECT id FROM TimeEntry WHERE endTime IS NULL LIMIT 1").get();
|
|
60
|
+
if (running) {
|
|
61
|
+
db.query(
|
|
62
|
+
\`UPDATE TimeEntry SET endTime = datetime('now'), updatedAt = datetime('now') WHERE id = ?\`
|
|
63
|
+
).run(running.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Start new timer
|
|
67
|
+
const newId = crypto.randomUUID();
|
|
68
|
+
db.query(
|
|
69
|
+
\`INSERT INTO TimeEntry (id, projectId, startTime, createdAt, updatedAt)
|
|
70
|
+
VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))\`
|
|
71
|
+
).run(newId, id);
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
db.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main();
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import Database from "bun:sqlite";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
const DB_PATH = join(homedir(), ".paca", "paca.db");
|
|
6
|
+
|
|
7
|
+
function openDb(): Database {
|
|
8
|
+
const db = new Database(DB_PATH);
|
|
9
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
10
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
11
|
+
return db;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RunningTimerRow {
|
|
15
|
+
id: string;
|
|
16
|
+
startTime: string;
|
|
17
|
+
projectId: string;
|
|
18
|
+
projectName: string;
|
|
19
|
+
projectColor: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProjectRow {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
color: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getRunningTimer(): RunningTimerRow | null {
|
|
29
|
+
const db = openDb();
|
|
30
|
+
try {
|
|
31
|
+
const row = db
|
|
32
|
+
.query<RunningTimerRow, []>(
|
|
33
|
+
`SELECT te.id, te.startTime, te.projectId,
|
|
34
|
+
p.name AS projectName, p.color AS projectColor
|
|
35
|
+
FROM TimeEntry te
|
|
36
|
+
JOIN Project p ON p.id = te.projectId
|
|
37
|
+
WHERE te.endTime IS NULL
|
|
38
|
+
LIMIT 1`,
|
|
39
|
+
)
|
|
40
|
+
.get();
|
|
41
|
+
return row ?? null;
|
|
42
|
+
} finally {
|
|
43
|
+
db.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getProjects(): ProjectRow[] {
|
|
48
|
+
const db = openDb();
|
|
49
|
+
try {
|
|
50
|
+
return db
|
|
51
|
+
.query<ProjectRow, []>(
|
|
52
|
+
`SELECT id, name, color FROM Project WHERE archived = 0 ORDER BY name ASC`,
|
|
53
|
+
)
|
|
54
|
+
.all();
|
|
55
|
+
} finally {
|
|
56
|
+
db.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function startTimer(projectId: string): void {
|
|
61
|
+
const db = openDb();
|
|
62
|
+
try {
|
|
63
|
+
// Stop any running timer first
|
|
64
|
+
const running = db
|
|
65
|
+
.query<{ id: string }, []>(
|
|
66
|
+
`SELECT id FROM TimeEntry WHERE endTime IS NULL LIMIT 1`,
|
|
67
|
+
)
|
|
68
|
+
.get();
|
|
69
|
+
if (running) {
|
|
70
|
+
db.query(
|
|
71
|
+
`UPDATE TimeEntry SET endTime = datetime('now'), updatedAt = datetime('now') WHERE id = ?`,
|
|
72
|
+
).run(running.id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Start new timer
|
|
76
|
+
const id = crypto.randomUUID();
|
|
77
|
+
db.query(
|
|
78
|
+
`INSERT INTO TimeEntry (id, projectId, startTime, createdAt, updatedAt)
|
|
79
|
+
VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))`,
|
|
80
|
+
).run(id, projectId);
|
|
81
|
+
} finally {
|
|
82
|
+
db.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function stopTimer(entryId: string, description?: string): void {
|
|
87
|
+
const db = openDb();
|
|
88
|
+
try {
|
|
89
|
+
db.query(
|
|
90
|
+
`UPDATE TimeEntry SET endTime = datetime('now'), description = ?, updatedAt = datetime('now') WHERE id = ?`,
|
|
91
|
+
).run(description ?? null, entryId);
|
|
92
|
+
} finally {
|
|
93
|
+
db.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync, chmodSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { getPluginScript } from "./plugin.ts";
|
|
6
|
+
import { getActionScript } from "./action.ts";
|
|
7
|
+
import { getRunningTimer, getProjects } from "./db-lite.ts";
|
|
8
|
+
|
|
9
|
+
const PLUGIN_FILENAME = "paca-timer.3s.ts";
|
|
10
|
+
const ACTION_FILENAME = "menubar-action.ts";
|
|
11
|
+
const PACA_DIR = join(homedir(), ".paca");
|
|
12
|
+
const ACTION_PATH = join(PACA_DIR, ACTION_FILENAME);
|
|
13
|
+
|
|
14
|
+
function resolveBunPath(): string {
|
|
15
|
+
try {
|
|
16
|
+
return execSync("which bun", { encoding: "utf-8" }).trim();
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error("Could not find bun in PATH. Is Bun installed?");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findSwiftBarPluginDir(): string | null {
|
|
23
|
+
// Try reading from SwiftBar defaults
|
|
24
|
+
try {
|
|
25
|
+
const dir = execSync(
|
|
26
|
+
"defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null",
|
|
27
|
+
{ encoding: "utf-8" },
|
|
28
|
+
).trim();
|
|
29
|
+
if (dir && existsSync(dir)) return dir;
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check common locations
|
|
35
|
+
const candidates = [
|
|
36
|
+
join(homedir(), "Library", "Application Support", "SwiftBar", "Plugins"),
|
|
37
|
+
join(homedir(), ".swiftbar"),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const dir of candidates) {
|
|
41
|
+
if (existsSync(dir)) return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isSwiftBarInstalled(): boolean {
|
|
48
|
+
return (
|
|
49
|
+
existsSync("/Applications/SwiftBar.app") ||
|
|
50
|
+
existsSync(join(homedir(), "Applications", "SwiftBar.app"))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatElapsed(startTimeStr: string): string {
|
|
55
|
+
const startMs = new Date(startTimeStr + "Z").getTime();
|
|
56
|
+
const elapsedMs = Date.now() - startMs;
|
|
57
|
+
const totalSec = Math.floor(elapsedMs / 1000);
|
|
58
|
+
const h = Math.floor(totalSec / 3600);
|
|
59
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
60
|
+
const s = totalSec % 60;
|
|
61
|
+
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function install() {
|
|
65
|
+
if (!isSwiftBarInstalled()) {
|
|
66
|
+
console.log("SwiftBar is not installed.");
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log("Install it with:");
|
|
69
|
+
console.log(" brew install --cask swiftbar");
|
|
70
|
+
console.log("");
|
|
71
|
+
console.log("Then run this command again:");
|
|
72
|
+
console.log(" paca menubar install");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pluginDir = findSwiftBarPluginDir();
|
|
77
|
+
if (!pluginDir) {
|
|
78
|
+
console.log("Could not find SwiftBar plugin directory.");
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log("Make sure SwiftBar has been launched at least once,");
|
|
81
|
+
console.log("or set a plugin directory in SwiftBar preferences.");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const bunPath = resolveBunPath();
|
|
86
|
+
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
|
|
87
|
+
|
|
88
|
+
// Write plugin script
|
|
89
|
+
writeFileSync(pluginPath, getPluginScript(bunPath), "utf-8");
|
|
90
|
+
chmodSync(pluginPath, 0o755);
|
|
91
|
+
|
|
92
|
+
// Write action script
|
|
93
|
+
writeFileSync(ACTION_PATH, getActionScript(bunPath), "utf-8");
|
|
94
|
+
chmodSync(ACTION_PATH, 0o755);
|
|
95
|
+
|
|
96
|
+
console.log("Paca menu bar plugin installed!");
|
|
97
|
+
console.log("");
|
|
98
|
+
console.log(` Plugin: ${pluginPath}`);
|
|
99
|
+
console.log(` Action: ${ACTION_PATH}`);
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log("The timer should appear in your menu bar shortly.");
|
|
102
|
+
console.log("If not, open SwiftBar and refresh plugins.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function uninstall() {
|
|
106
|
+
let removed = false;
|
|
107
|
+
|
|
108
|
+
// Remove plugin
|
|
109
|
+
const pluginDir = findSwiftBarPluginDir();
|
|
110
|
+
if (pluginDir) {
|
|
111
|
+
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
|
|
112
|
+
if (existsSync(pluginPath)) {
|
|
113
|
+
unlinkSync(pluginPath);
|
|
114
|
+
console.log(`Removed plugin: ${pluginPath}`);
|
|
115
|
+
removed = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Remove action script
|
|
120
|
+
if (existsSync(ACTION_PATH)) {
|
|
121
|
+
unlinkSync(ACTION_PATH);
|
|
122
|
+
console.log(`Removed action script: ${ACTION_PATH}`);
|
|
123
|
+
removed = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (removed) {
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log("Paca menu bar plugin uninstalled.");
|
|
129
|
+
} else {
|
|
130
|
+
console.log("No menu bar plugin files found to remove.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function status() {
|
|
135
|
+
const DB_PATH = join(PACA_DIR, "paca.db");
|
|
136
|
+
if (!existsSync(DB_PATH)) {
|
|
137
|
+
console.log("No database found. Run paca to initialize.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const running = getRunningTimer();
|
|
142
|
+
if (running) {
|
|
143
|
+
const elapsed = formatElapsed(running.startTime);
|
|
144
|
+
console.log(`Timer running: ${running.projectName} (${elapsed})`);
|
|
145
|
+
} else {
|
|
146
|
+
console.log("No timer running.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const projects = getProjects();
|
|
150
|
+
if (projects.length > 0) {
|
|
151
|
+
console.log("");
|
|
152
|
+
console.log("Projects:");
|
|
153
|
+
for (const p of projects) {
|
|
154
|
+
console.log(` - ${p.name}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function showHelp() {
|
|
160
|
+
const running = getRunningTimer();
|
|
161
|
+
if (running) {
|
|
162
|
+
const elapsed = formatElapsed(running.startTime);
|
|
163
|
+
console.log(`Timer: ${running.projectName} (${elapsed})`);
|
|
164
|
+
} else {
|
|
165
|
+
console.log("No timer running.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log("Menu bar commands:");
|
|
170
|
+
console.log(" paca menubar install Install SwiftBar plugin");
|
|
171
|
+
console.log(" paca menubar uninstall Remove SwiftBar plugin");
|
|
172
|
+
console.log(" paca menubar status Show current timer status");
|
|
173
|
+
console.log("");
|
|
174
|
+
|
|
175
|
+
if (!isSwiftBarInstalled()) {
|
|
176
|
+
console.log("SwiftBar is not installed. Install it with:");
|
|
177
|
+
console.log(" brew install --cask swiftbar");
|
|
178
|
+
} else {
|
|
179
|
+
const pluginDir = findSwiftBarPluginDir();
|
|
180
|
+
if (pluginDir) {
|
|
181
|
+
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
|
|
182
|
+
if (existsSync(pluginPath)) {
|
|
183
|
+
console.log("Status: Plugin installed");
|
|
184
|
+
} else {
|
|
185
|
+
console.log("Status: SwiftBar found, plugin not installed");
|
|
186
|
+
console.log(" Run: paca menubar install");
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
console.log("Status: SwiftBar found, but no plugin directory set");
|
|
190
|
+
console.log(" Launch SwiftBar first, then run: paca menubar install");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function menubarCommand(args: string[]) {
|
|
196
|
+
const subcommand = args[0];
|
|
197
|
+
|
|
198
|
+
switch (subcommand) {
|
|
199
|
+
case "install":
|
|
200
|
+
await install();
|
|
201
|
+
break;
|
|
202
|
+
case "uninstall":
|
|
203
|
+
await uninstall();
|
|
204
|
+
break;
|
|
205
|
+
case "status":
|
|
206
|
+
status();
|
|
207
|
+
break;
|
|
208
|
+
default:
|
|
209
|
+
showHelp();
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the content of the SwiftBar plugin script.
|
|
6
|
+
* The script is a self-contained Bun script that reads the Paca DB
|
|
7
|
+
* and outputs SwiftBar-formatted text.
|
|
8
|
+
*/
|
|
9
|
+
export function getPluginScript(bunPath: string): string {
|
|
10
|
+
const dbPath = join(homedir(), ".paca", "paca.db");
|
|
11
|
+
const actionPath = join(homedir(), ".paca", "menubar-action.ts");
|
|
12
|
+
|
|
13
|
+
return `#!${bunPath}
|
|
14
|
+
// Paca SwiftBar Plugin — auto-generated, do not edit
|
|
15
|
+
// Refresh: every 3 seconds (from filename)
|
|
16
|
+
|
|
17
|
+
import Database from "bun:sqlite";
|
|
18
|
+
|
|
19
|
+
const DB_PATH = ${JSON.stringify(dbPath)};
|
|
20
|
+
const ACTION = ${JSON.stringify(actionPath)};
|
|
21
|
+
const BUN = ${JSON.stringify(bunPath)};
|
|
22
|
+
|
|
23
|
+
function main() {
|
|
24
|
+
let db;
|
|
25
|
+
try {
|
|
26
|
+
db = new Database(DB_PATH, { readonly: true });
|
|
27
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
28
|
+
} catch {
|
|
29
|
+
console.log("⏸ Paca | color=#6c7086");
|
|
30
|
+
console.log("---");
|
|
31
|
+
console.log("Database not found | color=#f38ba8");
|
|
32
|
+
console.log("Run paca to initialize | color=#6c7086");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Check for running timer
|
|
38
|
+
const running = db
|
|
39
|
+
.query(
|
|
40
|
+
\`SELECT te.id, te.startTime, p.name AS projectName, p.color AS projectColor
|
|
41
|
+
FROM TimeEntry te
|
|
42
|
+
JOIN Project p ON p.id = te.projectId
|
|
43
|
+
WHERE te.endTime IS NULL
|
|
44
|
+
LIMIT 1\`
|
|
45
|
+
)
|
|
46
|
+
.get();
|
|
47
|
+
|
|
48
|
+
if (running) {
|
|
49
|
+
// Calculate elapsed time
|
|
50
|
+
const startMs = new Date(running.startTime + "Z").getTime();
|
|
51
|
+
const elapsedMs = Date.now() - startMs;
|
|
52
|
+
const totalSec = Math.floor(elapsedMs / 1000);
|
|
53
|
+
const h = Math.floor(totalSec / 3600);
|
|
54
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
55
|
+
const s = totalSec % 60;
|
|
56
|
+
const elapsed = \`\${h}:\${String(m).padStart(2, "0")}:\${String(s).padStart(2, "0")}\`;
|
|
57
|
+
|
|
58
|
+
console.log(\`▶ \${running.projectName} \${elapsed} | color=#a6e3a1 sfimage=timer\`);
|
|
59
|
+
console.log("---");
|
|
60
|
+
console.log(\`Stop Timer | bash=\${BUN} param1=\${ACTION} param2=stop param3=\${running.id} terminal=false refresh=true\`);
|
|
61
|
+
} else {
|
|
62
|
+
console.log("⏸ Paca | color=#6c7086 sfimage=timer");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log("---");
|
|
66
|
+
|
|
67
|
+
// List projects
|
|
68
|
+
const projects = db
|
|
69
|
+
.query("SELECT id, name, color FROM Project WHERE archived = 0 ORDER BY name ASC")
|
|
70
|
+
.all();
|
|
71
|
+
|
|
72
|
+
if (projects.length === 0) {
|
|
73
|
+
console.log("No projects yet | color=#6c7086");
|
|
74
|
+
console.log("Open paca to create one | color=#6c7086");
|
|
75
|
+
} else {
|
|
76
|
+
console.log("Start Timer For... | disabled=true color=#6c7086");
|
|
77
|
+
for (const p of projects) {
|
|
78
|
+
console.log(\`\${p.name} | bash=\${BUN} param1=\${ACTION} param2=start param3=\${p.id} terminal=false refresh=true color=\${p.color}\`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
db.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main();
|
|
87
|
+
`;
|
|
88
|
+
}
|