pacatui 0.1.13 → 0.1.15

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 CHANGED
@@ -16,6 +16,7 @@ A simple TUI app for task, timer and invoicing for projects.
16
16
  - **Stripe Invoicing** - Create draft invoices directly from time entries
17
17
  - **Invoice Management** - View and manage all your Stripe invoices
18
18
  - **Dashboard** - Overview of projects, tasks, and time stats
19
+ - **Menu Bar** - Native macOS menu bar companion for quick timer control
19
20
  - **Offline-first** - All data stored locally in SQLite
20
21
  - **Vim-style Navigation** - Keyboard-driven interface
21
22
 
@@ -120,6 +121,7 @@ Access settings by pressing `5`:
120
121
  - **Business Name** - Your business name for invoices
121
122
  - **Stripe API Key** - Enable invoicing features
122
123
  - **Timezone** - Set display timezone (or auto-detect)
124
+ - **Menu Bar** - Toggle the macOS menu bar companion
123
125
  - **Export/Import** - Backup and restore your data
124
126
 
125
127
  ### Data Location
@@ -127,6 +129,27 @@ Access settings by pressing `5`:
127
129
  - Database: `~/.paca/paca.db`
128
130
  - Backups: `~/.paca/backups/`
129
131
 
132
+ ## Menu Bar (macOS)
133
+
134
+ Paca includes a native macOS menu bar companion that shows your running timer and lets you start/stop timers without opening the TUI.
135
+
136
+ ### Enable via Settings
137
+
138
+ 1. Press `5` to open Settings
139
+ 2. Select **Menu Bar** and press `Enter` to toggle it on
140
+
141
+ ### Enable via CLI
142
+
143
+ ```bash
144
+ paca menubar enable # Compile & launch
145
+ paca menubar disable # Stop & remove
146
+ paca menubar status # Show current status
147
+ ```
148
+
149
+ The first time you enable it, Paca compiles a small native Swift helper binary (requires Xcode Command Line Tools). The paca mascot icon appears in your menu bar with live timer status.
150
+
151
+ **Requires**: Xcode Command Line Tools (`xcode-select --install`)
152
+
130
153
  ## Stripe Integration
131
154
 
132
155
  To enable invoicing:
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pacatui",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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"
package/src/App.tsx CHANGED
@@ -109,6 +109,7 @@ export function App() {
109
109
  stripeApiKey: "",
110
110
  timezone: "auto",
111
111
  theme: "catppuccin-mocha",
112
+ menuBar: "disabled",
112
113
  });
113
114
 
114
115
  // Timesheet State
@@ -364,6 +365,30 @@ export function App() {
364
365
  loadCustomers();
365
366
  }, []);
366
367
 
368
+ // Poll for external timer changes (e.g. from menu bar)
369
+ // Uses bun:sqlite directly to bypass Prisma's connection cache
370
+ useEffect(() => {
371
+ const poll = setInterval(async () => {
372
+ const { getRunningTimer: getRunningTimerLite } = await import("./menubar/db-lite.ts");
373
+ const row = getRunningTimerLite();
374
+ if (row) {
375
+ setRunningTimer({
376
+ id: row.id,
377
+ startTime: new Date(row.startTime.includes("T") ? row.startTime : row.startTime + "Z"),
378
+ project: {
379
+ id: row.projectId,
380
+ name: row.projectName,
381
+ color: row.projectColor,
382
+ hourlyRate: row.projectHourlyRate,
383
+ },
384
+ });
385
+ } else {
386
+ setRunningTimer(null);
387
+ }
388
+ }, 5000);
389
+ return () => clearInterval(poll);
390
+ }, []);
391
+
367
392
  // Reload tasks when project selection changes
368
393
  useEffect(() => {
369
394
  loadTasks();
@@ -860,10 +885,13 @@ export function App() {
860
885
  case 3: // Timezone
861
886
  setInputMode("edit_timezone");
862
887
  break;
863
- case 4: // Export Database
888
+ case 4: // Menu Bar
889
+ handleToggleMenuBar();
890
+ break;
891
+ case 5: // Export Database
864
892
  handleExportDatabase();
865
893
  break;
866
- case 5: // Import Database
894
+ case 6: // Import Database
867
895
  setConfirmMessage("Import will replace all data. Continue?");
868
896
  setConfirmAction(() => () => handleImportDatabase());
869
897
  break;
@@ -1177,6 +1205,18 @@ export function App() {
1177
1205
  showMessage(`Theme: ${theme.displayName}`);
1178
1206
  };
1179
1207
 
1208
+ const handleToggleMenuBar = async () => {
1209
+ const newValue = appSettings.menuBar === "enabled" ? "disabled" : "enabled";
1210
+ await settings.set("menuBar", newValue);
1211
+ setAppSettings((prev) => ({ ...prev, menuBar: newValue }));
1212
+
1213
+ const { enableMenuBar, disableMenuBar } = await import("./menubar/index.ts");
1214
+ const msg = newValue === "enabled"
1215
+ ? await enableMenuBar()
1216
+ : await disableMenuBar();
1217
+ showMessage(msg);
1218
+ };
1219
+
1180
1220
  // Modal handlers
1181
1221
  const handleCreateProject = async (name: string, rate: number | null) => {
1182
1222
  await projects.create({ name, hourlyRate: rate });
@@ -1437,6 +1477,7 @@ export function App() {
1437
1477
  onEditStripeKey={() => setInputMode("edit_stripe_key")}
1438
1478
  onEditTimezone={() => setInputMode("edit_timezone")}
1439
1479
  onSelectTheme={() => setInputMode("select_theme")}
1480
+ onToggleMenuBar={handleToggleMenuBar}
1440
1481
  onExportDatabase={handleExportDatabase}
1441
1482
  onImportDatabase={() => {
1442
1483
  setConfirmMessage("Import will replace all data. Continue?");
@@ -9,6 +9,7 @@ interface SettingsViewProps {
9
9
  onEditStripeKey: () => void;
10
10
  onEditTimezone: () => void;
11
11
  onSelectTheme: () => void;
12
+ onToggleMenuBar: () => void;
12
13
  onExportDatabase: () => void;
13
14
  onImportDatabase: () => void;
14
15
  }
@@ -18,6 +19,7 @@ const SETTINGS_ITEMS = [
18
19
  { key: "stripeApiKey", label: "Stripe API Key", type: "secret" },
19
20
  { key: "theme", label: "Theme", type: "select" },
20
21
  { key: "timezone", label: "Timezone", type: "text" },
22
+ { key: "menuBar", label: "Menu Bar", type: "toggle" },
21
23
  { key: "exportDatabase", label: "Export Database", type: "action" },
22
24
  { key: "importDatabase", label: "Import Database", type: "action" },
23
25
  ] as const;
@@ -30,6 +32,7 @@ export function SettingsView({
30
32
  onEditStripeKey,
31
33
  onEditTimezone,
32
34
  onSelectTheme,
35
+ onToggleMenuBar,
33
36
  onExportDatabase,
34
37
  onImportDatabase,
35
38
  }: SettingsViewProps) {
@@ -63,6 +66,8 @@ export function SettingsView({
63
66
  return maskSecret(settings.stripeApiKey);
64
67
  case "timezone":
65
68
  return formatTimezone(settings.timezone);
69
+ case "menuBar":
70
+ return settings.menuBar === "enabled" ? "Enabled" : "Disabled";
66
71
  case "exportDatabase":
67
72
  return "Export to file...";
68
73
  case "importDatabase":
@@ -82,6 +87,8 @@ export function SettingsView({
82
87
  return onEditStripeKey;
83
88
  case "timezone":
84
89
  return onEditTimezone;
90
+ case "menuBar":
91
+ return onToggleMenuBar;
85
92
  case "exportDatabase":
86
93
  return onExportDatabase;
87
94
  case "importDatabase":
@@ -94,6 +101,7 @@ export function SettingsView({
94
101
  const getActionLabel = (type: string) => {
95
102
  if (type === "action") return "run";
96
103
  if (type === "select") return "select";
104
+ if (type === "toggle") return "toggle";
97
105
  return "edit";
98
106
  };
99
107
 
package/src/db.ts CHANGED
@@ -631,6 +631,7 @@ export const settings = {
631
631
  stripeApiKey: all.stripeApiKey ?? "",
632
632
  timezone: all.timezone ?? "auto",
633
633
  theme: all.theme ?? "catppuccin-mocha",
634
+ menuBar: all.menuBar ?? "disabled",
634
635
  };
635
636
  },
636
637
  };
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);
@@ -177,6 +185,21 @@ async function main() {
177
185
  // Clean up tasks that have been done for more than 3 days
178
186
  await cleanupOldCompletedTasks(3);
179
187
 
188
+ // Auto-launch menu bar helper if enabled
189
+ try {
190
+ const db2 = new Database(DB_PATH, { readonly: true });
191
+ const menuBarSetting = db2
192
+ .query("SELECT value FROM Setting WHERE key = 'menuBar'")
193
+ .get() as { value: string } | null;
194
+ db2.close();
195
+ if (menuBarSetting?.value === "enabled") {
196
+ const { ensureMenuBarRunning } = await import("./menubar/index.ts");
197
+ ensureMenuBarRunning();
198
+ }
199
+ } catch {
200
+ // Non-critical — skip if menu bar can't be launched
201
+ }
202
+
180
203
  // Handle cleanup on exit - must stop renderer to restore terminal state
181
204
  const cleanup = async () => {
182
205
  if (renderer) {
@@ -0,0 +1,103 @@
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 openReadonly(): Database {
8
+ const db = new Database(DB_PATH, { readonly: true });
9
+ db.exec("PRAGMA busy_timeout=5000");
10
+ return db;
11
+ }
12
+
13
+ function openReadWrite(): Database {
14
+ const db = new Database(DB_PATH);
15
+ db.exec("PRAGMA busy_timeout=5000");
16
+ db.exec("PRAGMA journal_mode=WAL");
17
+ return db;
18
+ }
19
+
20
+ export interface RunningTimerRow {
21
+ id: string;
22
+ startTime: string;
23
+ projectId: string;
24
+ projectName: string;
25
+ projectColor: string;
26
+ projectHourlyRate: number | null;
27
+ }
28
+
29
+ export interface ProjectRow {
30
+ id: string;
31
+ name: string;
32
+ color: string;
33
+ }
34
+
35
+ export function getRunningTimer(): RunningTimerRow | null {
36
+ const db = openReadonly();
37
+ try {
38
+ const row = db
39
+ .query<RunningTimerRow, []>(
40
+ `SELECT te.id, te.startTime, te.projectId,
41
+ p.name AS projectName, p.color AS projectColor,
42
+ p.hourlyRate AS projectHourlyRate
43
+ FROM TimeEntry te
44
+ JOIN Project p ON p.id = te.projectId
45
+ WHERE te.endTime IS NULL
46
+ LIMIT 1`,
47
+ )
48
+ .get();
49
+ return row ?? null;
50
+ } finally {
51
+ db.close();
52
+ }
53
+ }
54
+
55
+ export function getProjects(): ProjectRow[] {
56
+ const db = openReadonly();
57
+ try {
58
+ return db
59
+ .query<ProjectRow, []>(
60
+ `SELECT id, name, color FROM Project WHERE archived = 0 ORDER BY name ASC`,
61
+ )
62
+ .all();
63
+ } finally {
64
+ db.close();
65
+ }
66
+ }
67
+
68
+ export function startTimer(projectId: string): void {
69
+ const db = openReadWrite();
70
+ try {
71
+ // Stop any running timer first
72
+ const running = db
73
+ .query<{ id: string }, []>(
74
+ `SELECT id FROM TimeEntry WHERE endTime IS NULL LIMIT 1`,
75
+ )
76
+ .get();
77
+ if (running) {
78
+ db.query(
79
+ `UPDATE TimeEntry SET endTime = datetime('now'), updatedAt = datetime('now') WHERE id = ?`,
80
+ ).run(running.id);
81
+ }
82
+
83
+ // Start new timer
84
+ const id = crypto.randomUUID();
85
+ db.query(
86
+ `INSERT INTO TimeEntry (id, projectId, startTime, createdAt, updatedAt)
87
+ VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))`,
88
+ ).run(id, projectId);
89
+ } finally {
90
+ db.close();
91
+ }
92
+ }
93
+
94
+ export function stopTimer(entryId: string, description?: string): void {
95
+ const db = openReadWrite();
96
+ try {
97
+ db.query(
98
+ `UPDATE TimeEntry SET endTime = datetime('now'), description = ?, updatedAt = datetime('now') WHERE id = ?`,
99
+ ).run(description ?? null, entryId);
100
+ } finally {
101
+ db.close();
102
+ }
103
+ }
@@ -0,0 +1,243 @@
1
+ import { existsSync, writeFileSync, unlinkSync, readFileSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { homedir } from "os";
4
+ import { execSync, spawn } from "child_process";
5
+ import { getSwiftSource } from "./swift-source.ts";
6
+ import { getRunningTimer, getProjects } from "./db-lite.ts";
7
+
8
+ const PACA_DIR = join(homedir(), ".paca");
9
+ const BINARY_PATH = join(PACA_DIR, "paca-menubar");
10
+ const SWIFT_PATH = join(PACA_DIR, "paca-menubar.swift");
11
+ const PID_PATH = join(PACA_DIR, "menubar.pid");
12
+ const ICON_PATH = join(PACA_DIR, "paca-icon.png");
13
+
14
+ function hasSwiftCompiler(): boolean {
15
+ try {
16
+ execSync("which swiftc", { encoding: "utf-8", stdio: "pipe" });
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function prepareMascotIcon(): string {
24
+ // Find the mascot image relative to source directory
25
+ const srcDir = dirname(dirname(import.meta.dir));
26
+ const mascotPath = join(srcDir, "assets", "paca-mascot.png");
27
+
28
+ if (!existsSync(mascotPath)) {
29
+ return "";
30
+ }
31
+
32
+ try {
33
+ // Resize to 36x36 (18pt @2x retina) using sips (built into macOS)
34
+ execSync(
35
+ `sips --resampleHeight 36 ${JSON.stringify(mascotPath)} --out ${JSON.stringify(ICON_PATH)} 2>/dev/null`,
36
+ { encoding: "utf-8", stdio: "pipe" },
37
+ );
38
+ const iconData = readFileSync(ICON_PATH);
39
+ unlinkSync(ICON_PATH);
40
+ return iconData.toString("base64");
41
+ } catch {
42
+ return "";
43
+ }
44
+ }
45
+
46
+ function compileBinary(): boolean {
47
+ const iconBase64 = prepareMascotIcon();
48
+ writeFileSync(SWIFT_PATH, getSwiftSource(iconBase64), "utf-8");
49
+ try {
50
+ execSync(
51
+ `swiftc -O -o ${JSON.stringify(BINARY_PATH)} ${JSON.stringify(SWIFT_PATH)} -framework Cocoa -lsqlite3 2>&1`,
52
+ { encoding: "utf-8", timeout: 60_000 },
53
+ );
54
+ unlinkSync(SWIFT_PATH);
55
+ return true;
56
+ } catch (error: any) {
57
+ console.error("Failed to compile menu bar helper:");
58
+ console.error(error.stdout || error.message);
59
+ if (existsSync(SWIFT_PATH)) unlinkSync(SWIFT_PATH);
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function readPid(): number | null {
65
+ try {
66
+ const pid = parseInt(readFileSync(PID_PATH, "utf-8").trim(), 10);
67
+ return isNaN(pid) ? null : pid;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function isProcessRunning(pid: number): boolean {
74
+ try {
75
+ process.kill(pid, 0);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ export function isMenuBarRunning(): boolean {
83
+ const pid = readPid();
84
+ return pid !== null && isProcessRunning(pid);
85
+ }
86
+
87
+ function launchHelper(): boolean {
88
+ if (!existsSync(BINARY_PATH)) return false;
89
+
90
+ const child = spawn(BINARY_PATH, [], {
91
+ detached: true,
92
+ stdio: "ignore",
93
+ });
94
+
95
+ if (child.pid) {
96
+ writeFileSync(PID_PATH, String(child.pid), "utf-8");
97
+ child.unref();
98
+ return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ function killHelper(): boolean {
104
+ const pid = readPid();
105
+ if (pid === null) return false;
106
+
107
+ try {
108
+ process.kill(pid, "SIGTERM");
109
+ } catch {
110
+ // Process already gone
111
+ }
112
+
113
+ if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Enable the menu bar helper. Called from settings toggle or CLI.
119
+ * Returns a status message string.
120
+ */
121
+ export async function enableMenuBar(): Promise<string> {
122
+ if (isMenuBarRunning()) {
123
+ return "Menu bar is already running.";
124
+ }
125
+
126
+ if (!hasSwiftCompiler()) {
127
+ return "Xcode Command Line Tools required. Install with: xcode-select --install";
128
+ }
129
+
130
+ // Compile if binary doesn't exist
131
+ if (!existsSync(BINARY_PATH)) {
132
+ const ok = compileBinary();
133
+ if (!ok) return "Failed to compile menu bar helper.";
134
+ }
135
+
136
+ const launched = launchHelper();
137
+ if (!launched) return "Failed to launch menu bar helper.";
138
+
139
+ return "Menu bar enabled.";
140
+ }
141
+
142
+ /**
143
+ * Disable the menu bar helper. Called from settings toggle or CLI.
144
+ * Returns a status message string.
145
+ */
146
+ export async function disableMenuBar(): Promise<string> {
147
+ killHelper();
148
+
149
+ // Remove binary
150
+ if (existsSync(BINARY_PATH)) unlinkSync(BINARY_PATH);
151
+
152
+ return "Menu bar disabled.";
153
+ }
154
+
155
+ /**
156
+ * Ensure the helper is running if the setting is enabled.
157
+ * Called on paca startup — no-op if already running or not enabled.
158
+ */
159
+ export function ensureMenuBarRunning(): void {
160
+ if (isMenuBarRunning()) return;
161
+ if (!existsSync(BINARY_PATH)) return;
162
+ launchHelper();
163
+ }
164
+
165
+ // --- CLI ---
166
+
167
+ function formatElapsed(startTimeStr: string): string {
168
+ const startMs = new Date(startTimeStr + "Z").getTime();
169
+ const elapsedMs = Date.now() - startMs;
170
+ const totalSec = Math.floor(elapsedMs / 1000);
171
+ const h = Math.floor(totalSec / 3600);
172
+ const m = Math.floor((totalSec % 3600) / 60);
173
+ const s = totalSec % 60;
174
+ return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
175
+ }
176
+
177
+ async function cliEnable() {
178
+ console.log("Compiling menu bar helper...");
179
+ const msg = await enableMenuBar();
180
+ console.log(msg);
181
+ }
182
+
183
+ async function cliDisable() {
184
+ const msg = await disableMenuBar();
185
+ console.log(msg);
186
+ }
187
+
188
+ function cliStatus() {
189
+ const dbPath = join(PACA_DIR, "paca.db");
190
+ if (!existsSync(dbPath)) {
191
+ console.log("No database found. Run paca to initialize.");
192
+ return;
193
+ }
194
+
195
+ console.log(`Menu bar: ${isMenuBarRunning() ? "running" : "not running"}`);
196
+ console.log(`Binary: ${existsSync(BINARY_PATH) ? "compiled" : "not compiled"}`);
197
+ console.log("");
198
+
199
+ const running = getRunningTimer();
200
+ if (running) {
201
+ const elapsed = formatElapsed(running.startTime);
202
+ console.log(`Timer running: ${running.projectName} (${elapsed})`);
203
+ } else {
204
+ console.log("No timer running.");
205
+ }
206
+
207
+ const allProjects = getProjects();
208
+ if (allProjects.length > 0) {
209
+ console.log("");
210
+ console.log("Projects:");
211
+ for (const p of allProjects) {
212
+ console.log(` - ${p.name}`);
213
+ }
214
+ }
215
+ }
216
+
217
+ function showHelp() {
218
+ console.log("Menu bar commands:");
219
+ console.log(" paca menubar enable Compile & launch menu bar helper");
220
+ console.log(" paca menubar disable Stop & remove menu bar helper");
221
+ console.log(" paca menubar status Show current status");
222
+ console.log("");
223
+ console.log(`Menu bar: ${isMenuBarRunning() ? "running" : "not running"}`);
224
+ console.log("");
225
+ console.log("You can also toggle this from Settings in the TUI.");
226
+ }
227
+
228
+ export async function menubarCommand(args: string[]) {
229
+ switch (args[0]) {
230
+ case "enable":
231
+ await cliEnable();
232
+ break;
233
+ case "disable":
234
+ await cliDisable();
235
+ break;
236
+ case "status":
237
+ cliStatus();
238
+ break;
239
+ default:
240
+ showHelp();
241
+ break;
242
+ }
243
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Native macOS menu bar helper — Swift source code.
3
+ * Compiled with `swiftc` at install time, stored at ~/.paca/paca-menubar.
4
+ *
5
+ * @param iconBase64 - Base64-encoded PNG of the paca mascot (36x36 for retina)
6
+ */
7
+ export function getSwiftSource(iconBase64: string): string {
8
+ return `
9
+ import Cocoa
10
+ import SQLite3
11
+
12
+ let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
13
+
14
+ let ICON_BASE64 = "${iconBase64}"
15
+
16
+ func loadIcon() -> NSImage? {
17
+ guard let data = Data(base64Encoded: ICON_BASE64) else { return nil }
18
+ guard let image = NSImage(data: data) else { return nil }
19
+ // Set point size to 18x18 (the data is @2x retina)
20
+ image.size = NSSize(width: 18, height: 18)
21
+ return image
22
+ }
23
+
24
+ class AppDelegate: NSObject, NSApplicationDelegate {
25
+ var statusItem: NSStatusItem!
26
+ var pollTimer: Timer?
27
+ let dbPath: String
28
+ var icon: NSImage?
29
+
30
+ override init() {
31
+ let home = FileManager.default.homeDirectoryForCurrentUser
32
+ dbPath = home.appendingPathComponent(".paca/paca.db").path
33
+ super.init()
34
+ }
35
+
36
+ func applicationDidFinishLaunching(_ notification: Notification) {
37
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
38
+ icon = loadIcon()
39
+ statusItem.button?.imagePosition = .imageLeading
40
+ updateMenu()
41
+ pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
42
+ DispatchQueue.main.async { self?.updateMenu() }
43
+ }
44
+ }
45
+
46
+ func updateMenu() {
47
+ var db: OpaquePointer?
48
+ guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
49
+ setIdleAppearance()
50
+ statusItem.menu = buildFallbackMenu("Database not found")
51
+ return
52
+ }
53
+ defer { sqlite3_close(db) }
54
+
55
+ sqlite3_busy_timeout(db, 5000)
56
+
57
+ // Check for running timer
58
+ var stmt: OpaquePointer?
59
+ let runningSQL = """
60
+ SELECT te.id, te.startTime, p.name, p.color
61
+ FROM TimeEntry te JOIN Project p ON p.id = te.projectId
62
+ WHERE te.endTime IS NULL LIMIT 1
63
+ """
64
+ var runningId: String?
65
+ var startTime: String?
66
+ var projectName: String?
67
+
68
+ if sqlite3_prepare_v2(db, runningSQL, -1, &stmt, nil) == SQLITE_OK {
69
+ if sqlite3_step(stmt) == SQLITE_ROW {
70
+ runningId = col(stmt, 0)
71
+ startTime = col(stmt, 1)
72
+ projectName = col(stmt, 2)
73
+ }
74
+ }
75
+ sqlite3_finalize(stmt)
76
+
77
+ // Update title and icon
78
+ if let name = projectName, let start = startTime {
79
+ let elapsed = formatElapsed(start)
80
+ let title = " ▶ \\(name) \\(elapsed)"
81
+ let attrs: [NSAttributedString.Key: Any] = [
82
+ .foregroundColor: NSColor(red: 0.65, green: 0.89, blue: 0.63, alpha: 1.0),
83
+ .font: NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .medium)
84
+ ]
85
+ statusItem.button?.attributedTitle = NSAttributedString(string: title, attributes: attrs)
86
+ statusItem.button?.image = icon
87
+ } else {
88
+ setIdleAppearance()
89
+ }
90
+
91
+ // Build menu
92
+ let menu = NSMenu()
93
+
94
+ if let entryId = runningId {
95
+ let stop = NSMenuItem(title: "Stop Timer", action: #selector(stopTimerClicked(_:)), keyEquivalent: "")
96
+ stop.target = self
97
+ stop.representedObject = entryId
98
+ menu.addItem(stop)
99
+ } else {
100
+ // Only show project list when no timer is running
101
+ var pStmt: OpaquePointer?
102
+ let pSQL = "SELECT id, name FROM Project WHERE archived = 0 ORDER BY name ASC"
103
+ var projectItems: [(id: String, name: String)] = []
104
+
105
+ if sqlite3_prepare_v2(db, pSQL, -1, &pStmt, nil) == SQLITE_OK {
106
+ while sqlite3_step(pStmt) == SQLITE_ROW {
107
+ if let id = col(pStmt, 0), let name = col(pStmt, 1) {
108
+ projectItems.append((id: id, name: name))
109
+ }
110
+ }
111
+ }
112
+ sqlite3_finalize(pStmt)
113
+
114
+ if projectItems.isEmpty {
115
+ let empty = NSMenuItem(title: "No projects — open paca to create one", action: nil, keyEquivalent: "")
116
+ empty.isEnabled = false
117
+ menu.addItem(empty)
118
+ } else {
119
+ let header = NSMenuItem(title: "Start Timer For...", action: nil, keyEquivalent: "")
120
+ header.isEnabled = false
121
+ menu.addItem(header)
122
+ for p in projectItems {
123
+ let item = NSMenuItem(title: " \\(p.name)", action: #selector(startTimerClicked(_:)), keyEquivalent: "")
124
+ item.target = self
125
+ item.representedObject = p.id
126
+ menu.addItem(item)
127
+ }
128
+ }
129
+ }
130
+
131
+ menu.addItem(NSMenuItem.separator())
132
+ let quit = NSMenuItem(title: "Quit Paca Menu Bar", action: #selector(quitApp), keyEquivalent: "q")
133
+ quit.target = self
134
+ menu.addItem(quit)
135
+
136
+ statusItem.menu = menu
137
+ }
138
+
139
+ func setIdleAppearance() {
140
+ statusItem.button?.image = icon
141
+ statusItem.button?.title = ""
142
+ statusItem.button?.attributedTitle = NSAttributedString(string: "")
143
+ }
144
+
145
+ func buildFallbackMenu(_ message: String) -> NSMenu {
146
+ let menu = NSMenu()
147
+ let item = NSMenuItem(title: message, action: nil, keyEquivalent: "")
148
+ item.isEnabled = false
149
+ menu.addItem(item)
150
+ menu.addItem(NSMenuItem.separator())
151
+ let quit = NSMenuItem(title: "Quit Paca Menu Bar", action: #selector(quitApp), keyEquivalent: "q")
152
+ quit.target = self
153
+ menu.addItem(quit)
154
+ return menu
155
+ }
156
+
157
+ func col(_ stmt: OpaquePointer?, _ idx: Int32) -> String? {
158
+ guard let cStr = sqlite3_column_text(stmt, idx) else { return nil }
159
+ return String(cString: cStr)
160
+ }
161
+
162
+ func parseDate(_ str: String) -> Date? {
163
+ // Try ISO8601DateFormatter first (handles Z, +00:00, etc.)
164
+ let iso = ISO8601DateFormatter()
165
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
166
+ if let d = iso.date(from: str) { return d }
167
+
168
+ // Without fractional seconds
169
+ iso.formatOptions = [.withInternetDateTime]
170
+ if let d = iso.date(from: str) { return d }
171
+
172
+ // Try SQLite datetime() format
173
+ let sql = DateFormatter()
174
+ sql.dateFormat = "yyyy-MM-dd HH:mm:ss"
175
+ sql.timeZone = TimeZone(identifier: "UTC")
176
+ return sql.date(from: str)
177
+ }
178
+
179
+ func formatElapsed(_ startTimeStr: String) -> String {
180
+ guard let start = parseDate(startTimeStr) else { return "0:00:00" }
181
+
182
+ let elapsed = max(0, Int(Date().timeIntervalSince(start)))
183
+ let h = elapsed / 3600
184
+ let m = (elapsed % 3600) / 60
185
+ let s = elapsed % 60
186
+ return String(format: "%d:%02d:%02d", h, m, s)
187
+ }
188
+
189
+ // MARK: - Actions
190
+
191
+ @objc func stopTimerClicked(_ sender: NSMenuItem) {
192
+ guard let entryId = sender.representedObject as? String else { return }
193
+
194
+ let alert = NSAlert()
195
+ alert.messageText = "What did you work on?"
196
+ alert.addButton(withTitle: "Save")
197
+ alert.addButton(withTitle: "Skip")
198
+ alert.addButton(withTitle: "Cancel")
199
+
200
+ let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
201
+ input.placeholderString = "Description (optional)"
202
+ alert.accessoryView = input
203
+ alert.window.initialFirstResponder = input
204
+
205
+ NSApp.activate(ignoringOtherApps: true)
206
+ let response = alert.runModal()
207
+
208
+ if response == .alertThirdButtonReturn { return }
209
+
210
+ let desc = response == .alertFirstButtonReturn ? input.stringValue : nil
211
+
212
+ var db: OpaquePointer?
213
+ guard sqlite3_open(dbPath, &db) == SQLITE_OK else { return }
214
+ defer { sqlite3_close(db) }
215
+ sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
216
+ sqlite3_busy_timeout(db, 5000)
217
+
218
+ var stmt: OpaquePointer?
219
+ let sql = "UPDATE TimeEntry SET endTime = datetime('now'), description = ?, updatedAt = datetime('now') WHERE id = ?"
220
+ if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK {
221
+ if let d = desc, !d.isEmpty {
222
+ sqlite3_bind_text(stmt, 1, (d as NSString).utf8String, -1, SQLITE_TRANSIENT)
223
+ } else {
224
+ sqlite3_bind_null(stmt, 1)
225
+ }
226
+ sqlite3_bind_text(stmt, 2, (entryId as NSString).utf8String, -1, SQLITE_TRANSIENT)
227
+ sqlite3_step(stmt)
228
+ }
229
+ sqlite3_finalize(stmt)
230
+
231
+ updateMenu()
232
+ }
233
+
234
+ @objc func startTimerClicked(_ sender: NSMenuItem) {
235
+ guard let projectId = sender.representedObject as? String else { return }
236
+
237
+ var db: OpaquePointer?
238
+ guard sqlite3_open(dbPath, &db) == SQLITE_OK else { return }
239
+ defer { sqlite3_close(db) }
240
+ sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
241
+ sqlite3_busy_timeout(db, 5000)
242
+
243
+ sqlite3_exec(db, "UPDATE TimeEntry SET endTime = datetime('now'), updatedAt = datetime('now') WHERE endTime IS NULL", nil, nil, nil)
244
+
245
+ let id = UUID().uuidString.lowercased()
246
+ var stmt: OpaquePointer?
247
+ let sql = "INSERT INTO TimeEntry (id, projectId, startTime, createdAt, updatedAt) VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))"
248
+ if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK {
249
+ sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, SQLITE_TRANSIENT)
250
+ sqlite3_bind_text(stmt, 2, (projectId as NSString).utf8String, -1, SQLITE_TRANSIENT)
251
+ sqlite3_step(stmt)
252
+ }
253
+ sqlite3_finalize(stmt)
254
+
255
+ updateMenu()
256
+ }
257
+
258
+ @objc func quitApp() {
259
+ NSApp.terminate(nil)
260
+ }
261
+ }
262
+
263
+ let app = NSApplication.shared
264
+ app.setActivationPolicy(.accessory)
265
+ let delegate = AppDelegate()
266
+ app.delegate = delegate
267
+ app.run()
268
+ `;
269
+ }
package/src/types.ts CHANGED
@@ -102,6 +102,7 @@ export interface AppSettings {
102
102
  stripeApiKey: string;
103
103
  timezone: string; // IANA timezone (e.g., "America/New_York") or "auto" for system detection
104
104
  theme: string; // Theme name (e.g., "catppuccin-mocha", "neon")
105
+ menuBar: string; // "enabled" or "disabled"
105
106
  }
106
107
 
107
108
  // Theme color definitions