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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pacatui",
3
- "version": "0.1.11",
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
- const chartWidth = Math.max(width + 4, 40);
113
- const maxBars = Math.min(data.length, 25, Math.floor(chartWidth / 3)); // Each bar needs ~3 chars min, max 25 weeks
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}>No time entries in the last 6 months</text>
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
- // Find max hours for scaling
125
- const maxMs = Math.max(...recentData.map((d) => d.totalMs), 1);
126
- const barHeight = 5; // Height of bars in rows
127
-
128
- // Calculate bar width to fill available space
129
- // Total width = (barWidth * n) + (n - 1) spaces = chartWidth
130
- // barWidth = (chartWidth - n + 1) / n
131
-
132
- const barWidth = 4;
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 across all weeks)
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 the global order for consistent stacking
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
- // Pre-calculate bar heights for label positioning
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
- if (row < barTotalRows) {
188
- // This row should be filled - determine which project's color
189
- // Use fraction-based calculation to avoid rounding errors
190
- const rowFraction = (row + 0.5) / barTotalRows;
191
- const weekProjects = weekProjectsSorted[i]!;
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
- // Find which project this row belongs to based on cumulative fraction
194
- let cumulativeFraction = 0;
195
- let projectColor = chartColors[0]!;
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
- const projFraction = proj.ms / week.totalMs;
199
- cumulativeFraction += projFraction;
200
- if (rowFraction <= cumulativeFraction) {
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
- rowParts.push(
208
- <span key={i} fg={projectColor}>
209
- {"█".repeat(barWidth)}
210
- </span>,
211
- );
212
- } else if (row === barTotalRows && week.totalMs > 0) {
213
- // Show hour label just above the bar (centered)
214
- const label = formatHours(week.totalMs);
215
- const trimmed = label.slice(0, barWidth);
216
- const padLeft = Math.floor((barWidth - trimmed.length) / 2);
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
- trimmed +
220
- " ".repeat(barWidth - padLeft - trimmed.length);
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
- rowParts.push(<span key={i}>{" ".repeat(barWidth)}</span>);
228
- }
229
- // Add space between bars
230
- if (i < recentData.length - 1) {
231
- rowParts.push(<span key={`sp-${i}`}> </span>);
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
- // Calculate total hours for display
239
- const totalMs = recentData.reduce((sum, w) => sum + w.totalMs, 0);
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
- const week = recentData[i]!;
245
- const trimmed = week.weekLabel.slice(0, barWidth);
246
- const padLeft = Math.floor((barWidth - trimmed.length) / 2);
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
- dateLabelParts.push(<span key={`sp-${i}`}> </span>);
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", gap: 1, width: "100%" }}>
263
- {/* Bars (with hour labels at top) */}
350
+ <box style={{ flexDirection: "column", width: "100%" }}>
264
351
  {rows}
265
- {/* Date labels */}
266
- <text>{dateLabelParts}</text>
267
-
268
- {/* Legend - show top projects by time */}
352
+ {/* Legend */}
269
353
  <box
270
- style={{ flexDirection: "row", gap: 2, marginTop: 1, flexWrap: "wrap" }}
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}> {proj.name.slice(0, 12)}</span>
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
+ }