pacatui 0.1.15 → 0.1.16

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.
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pacatui",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "A simple tui app for task, timer and invoicing for projects.",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/App.tsx CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  EditTimeEntryModal,
21
21
  CreateInvoiceModal,
22
22
  ThemeSelectModal,
23
+ DatabaseSelectModal,
23
24
  } from "./components/index.ts";
24
25
  import { InvoicesView } from "./components/InvoicesView.tsx";
25
26
  import {
@@ -31,7 +32,16 @@ import {
31
32
  database,
32
33
  customers,
33
34
  invoices,
35
+ switchDatabase,
34
36
  } from "./db.ts";
37
+ import {
38
+ getActiveDbFilename,
39
+ setActiveDbFilename,
40
+ getActiveDbPath,
41
+ listDatabases,
42
+ sanitizeDbName,
43
+ getPacaDir,
44
+ } from "./db-path.ts";
35
45
  import { getOrCreateStripeCustomer, createDraftInvoice, listInvoices, clearInvoiceCache, type StripeInvoiceItem } from "./stripe.ts";
36
46
  import {
37
47
  getEffectiveTimezone,
@@ -152,6 +162,9 @@ export function App() {
152
162
  const [invoicesHasMore, setInvoicesHasMore] = useState(false);
153
163
  const [invoicesCursors, setInvoicesCursors] = useState<string[]>([]); // Stack of cursors for pagination
154
164
 
165
+ // Database State
166
+ const [currentDbFilename, setCurrentDbFilename] = useState(getActiveDbFilename());
167
+
155
168
  // Modal State
156
169
  const [inputMode, setInputMode] = useState<InputMode | null>(null);
157
170
  const [statusMessage, setStatusMessage] =
@@ -498,14 +511,19 @@ export function App() {
498
511
  return;
499
512
  }
500
513
 
501
- // Handle input mode escape (except select_timer_project which has its own handler)
502
- if (inputMode && inputMode !== "select_timer_project") {
514
+ // Handle input mode escape (except modals that handle their own escape)
515
+ if (inputMode && inputMode !== "select_timer_project" && inputMode !== "select_database" && inputMode !== "create_database_name") {
503
516
  if (key.name === "escape") {
504
517
  setInputMode(null);
505
518
  }
506
519
  return;
507
520
  }
508
521
 
522
+ // Handle select_database and create_database_name modals (they have their own handlers)
523
+ if (inputMode === "select_database" || inputMode === "create_database_name") {
524
+ return;
525
+ }
526
+
509
527
  // Handle select timer project modal
510
528
  if (inputMode === "select_timer_project") {
511
529
  // The modal has its own keyboard handler
@@ -871,6 +889,7 @@ export function App() {
871
889
  }
872
890
 
873
891
  // Select/activate setting
892
+ // Order matches: Business (0: businessName, 1: stripeApiKey) + App (2: database, 3: theme, 4: timezone, 5: menuBar, 6: export, 7: import)
874
893
  if (key.name === "return") {
875
894
  switch (selectedSettingsIndex) {
876
895
  case 0: // Business Name
@@ -879,19 +898,22 @@ export function App() {
879
898
  case 1: // Stripe API Key
880
899
  setInputMode("edit_stripe_key");
881
900
  break;
882
- case 2: // Theme
901
+ case 2: // Database
902
+ setInputMode("select_database");
903
+ break;
904
+ case 3: // Theme
883
905
  setInputMode("select_theme");
884
906
  break;
885
- case 3: // Timezone
907
+ case 4: // Timezone
886
908
  setInputMode("edit_timezone");
887
909
  break;
888
- case 4: // Menu Bar
910
+ case 5: // Menu Bar
889
911
  handleToggleMenuBar();
890
912
  break;
891
- case 5: // Export Database
913
+ case 6: // Export Database
892
914
  handleExportDatabase();
893
915
  break;
894
- case 6: // Import Database
916
+ case 7: // Import Database
895
917
  setConfirmMessage("Import will replace all data. Continue?");
896
918
  setConfirmAction(() => () => handleImportDatabase());
897
919
  break;
@@ -1160,7 +1182,8 @@ export function App() {
1160
1182
  }
1161
1183
 
1162
1184
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1163
- const exportPath = `${backupDir}/paca-backup-${timestamp}.db`;
1185
+ const dbName = currentDbFilename.replace(/\.db$/, "");
1186
+ const exportPath = `${backupDir}/${dbName}-backup-${timestamp}.db`;
1164
1187
  try {
1165
1188
  await database.exportToFile(exportPath);
1166
1189
  showMessage(`Exported to ${exportPath}`);
@@ -1217,6 +1240,105 @@ export function App() {
1217
1240
  showMessage(msg);
1218
1241
  };
1219
1242
 
1243
+ // Database handlers
1244
+ const handleSwitchDatabase = async (filename: string) => {
1245
+ if (filename === currentDbFilename) {
1246
+ setInputMode(null);
1247
+ return;
1248
+ }
1249
+
1250
+ try {
1251
+ const { join } = await import("path");
1252
+ const { existsSync } = await import("fs");
1253
+ const dbPath = join(getPacaDir(), filename);
1254
+
1255
+ // Run migration if database file doesn't exist yet
1256
+ if (!existsSync(dbPath)) {
1257
+ const { execSync } = await import("child_process");
1258
+ const { dirname } = await import("path");
1259
+ const projectDir = dirname(dirname(import.meta.path));
1260
+ execSync(`cd "${projectDir}" && bunx prisma migrate deploy`, {
1261
+ stdio: "pipe",
1262
+ env: {
1263
+ ...process.env,
1264
+ DATABASE_URL: `file:${dbPath}`,
1265
+ },
1266
+ });
1267
+ }
1268
+
1269
+ // Disable menu bar before switching (it points at the old db)
1270
+ if (appSettings.menuBar === "enabled") {
1271
+ const { disableMenuBar } = await import("./menubar/index.ts");
1272
+ await disableMenuBar();
1273
+ await settings.set("menuBar", "disabled");
1274
+ setAppSettings((prev) => ({ ...prev, menuBar: "disabled" }));
1275
+ }
1276
+
1277
+ // Write .active file
1278
+ setActiveDbFilename(filename);
1279
+
1280
+ // Swap PrismaClient
1281
+ await switchDatabase(dbPath);
1282
+
1283
+ // Update state
1284
+ setCurrentDbFilename(filename);
1285
+
1286
+ // Reload all app state
1287
+ const [settingsData, projectData, dashboardData, recentData, weeklyData, runningData, customerData] = await Promise.all([
1288
+ settings.getAppSettings(),
1289
+ projects.getAll(false),
1290
+ stats.getDashboardStats(),
1291
+ stats.getRecentActivity(10),
1292
+ stats.getWeeklyTimeStats(6),
1293
+ timeEntries.getRunning(),
1294
+ customers.getAll(),
1295
+ ]);
1296
+
1297
+ setAppSettings(settingsData);
1298
+ setProjectList(projectData);
1299
+ setDashboardStats(dashboardData);
1300
+ setRecentTasks(recentData as (Task & { project: { name: string; color: string } })[]);
1301
+ setWeeklyTimeData(weeklyData);
1302
+ setCustomerList(customerData);
1303
+
1304
+ if (runningData) {
1305
+ setRunningTimer({
1306
+ id: runningData.id,
1307
+ startTime: runningData.startTime,
1308
+ project: runningData.project,
1309
+ });
1310
+ } else {
1311
+ setRunningTimer(null);
1312
+ }
1313
+
1314
+ // Reset selection indices
1315
+ setSelectedProjectIndex(0);
1316
+ setSelectedTaskIndex(0);
1317
+ setSelectedDashboardTaskIndex(0);
1318
+ setSelectedSettingsIndex(0);
1319
+
1320
+ setInputMode(null);
1321
+ showMessage(`Switched to database: ${filename.replace(/\.db$/, "")}`);
1322
+ } catch (error) {
1323
+ showMessage(`Failed to switch database: ${error}`);
1324
+ }
1325
+ };
1326
+
1327
+ const handleCreateDatabase = async (name: string) => {
1328
+ const filename = sanitizeDbName(name);
1329
+ const { join } = await import("path");
1330
+ const { existsSync } = await import("fs");
1331
+ const dbPath = join(getPacaDir(), filename);
1332
+
1333
+ if (existsSync(dbPath)) {
1334
+ showMessage(`Database "${filename.replace(/\.db$/, "")}" already exists`);
1335
+ setInputMode("select_database");
1336
+ return;
1337
+ }
1338
+
1339
+ await handleSwitchDatabase(filename);
1340
+ };
1341
+
1220
1342
  // Modal handlers
1221
1343
  const handleCreateProject = async (name: string, rate: number | null) => {
1222
1344
  await projects.create({ name, hourlyRate: rate });
@@ -1473,6 +1595,8 @@ export function App() {
1473
1595
  settings={appSettings}
1474
1596
  selectedIndex={selectedSettingsIndex}
1475
1597
  theme={getTheme(appSettings.theme)}
1598
+ currentDbFilename={currentDbFilename}
1599
+ onSelectDatabase={() => setInputMode("select_database")}
1476
1600
  onEditBusinessName={() => setInputMode("edit_business_name")}
1477
1601
  onEditStripeKey={() => setInputMode("edit_stripe_key")}
1478
1602
  onEditTimezone={() => setInputMode("edit_timezone")}
@@ -1667,6 +1791,28 @@ export function App() {
1667
1791
  />
1668
1792
  )}
1669
1793
 
1794
+ {inputMode === "select_database" && (
1795
+ <DatabaseSelectModal
1796
+ databases={listDatabases()}
1797
+ currentDatabase={currentDbFilename}
1798
+ onSelect={handleSwitchDatabase}
1799
+ onCreateNew={() => setInputMode("create_database_name")}
1800
+ onCancel={() => setInputMode(null)}
1801
+ theme={theme}
1802
+ />
1803
+ )}
1804
+
1805
+ {inputMode === "create_database_name" && (
1806
+ <InputModal
1807
+ mode={inputMode}
1808
+ title="Create New Database"
1809
+ placeholder="Database name (e.g. work, personal)..."
1810
+ onSubmit={handleCreateDatabase}
1811
+ onCancel={() => setInputMode("select_database")}
1812
+ theme={theme}
1813
+ />
1814
+ )}
1815
+
1670
1816
  {inputMode === "select_timer_project" && (
1671
1817
  <ProjectSelectModal
1672
1818
  projects={projectList.filter((p) => !p.archived)}
@@ -0,0 +1,124 @@
1
+ import { useState } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import type { Theme } from "../types.ts";
4
+ import Modal from "./Modal.tsx";
5
+
6
+ interface DatabaseSelectModalProps {
7
+ databases: string[];
8
+ currentDatabase: string;
9
+ onSelect: (filename: string) => void;
10
+ onCreateNew: () => void;
11
+ onCancel: () => void;
12
+ theme: Theme;
13
+ }
14
+
15
+ export function DatabaseSelectModal({
16
+ databases,
17
+ currentDatabase,
18
+ onSelect,
19
+ onCreateNew,
20
+ onCancel,
21
+ theme,
22
+ }: DatabaseSelectModalProps) {
23
+ const currentIndex = databases.indexOf(currentDatabase);
24
+ const [selectedIndex, setSelectedIndex] = useState(
25
+ currentIndex >= 0 ? currentIndex : 0,
26
+ );
27
+
28
+ const colors = theme.colors;
29
+
30
+ // Total items = databases + "Create new" option
31
+ const totalItems = databases.length + 1;
32
+
33
+ useKeyboard((key) => {
34
+ if (key.name === "escape") {
35
+ onCancel();
36
+ return;
37
+ }
38
+ if (key.name === "return") {
39
+ if (selectedIndex === databases.length) {
40
+ onCreateNew();
41
+ } else {
42
+ const selected = databases[selectedIndex];
43
+ if (selected) {
44
+ onSelect(selected);
45
+ }
46
+ }
47
+ return;
48
+ }
49
+ if (key.name === "down" || key.name === "j") {
50
+ setSelectedIndex((i) => Math.min(i + 1, totalItems - 1));
51
+ return;
52
+ }
53
+ if (key.name === "up" || key.name === "k") {
54
+ setSelectedIndex((i) => Math.max(i - 1, 0));
55
+ return;
56
+ }
57
+ });
58
+
59
+ // border(2) + padding(2) + title(1) + marginTop(1) + items + spacer(1) + create_new(1) + marginTop(1) + help(1)
60
+ const height = Math.min(totalItems + 10, 20);
61
+
62
+ return (
63
+ <Modal title="Select Database" height={height} theme={theme}>
64
+ <box style={{ flexGrow: 1, marginTop: 1 }}>
65
+ {databases.map((db, index) => {
66
+ const isSelected = index === selectedIndex;
67
+ const isCurrent = db === currentDatabase;
68
+ const displayName = db.replace(/\.db$/, "");
69
+
70
+ return (
71
+ <box
72
+ key={db}
73
+ style={{
74
+ paddingLeft: 1,
75
+ paddingRight: 1,
76
+ backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
77
+ }}
78
+ >
79
+ <text>
80
+ <span fg={isSelected ? colors.selectedText : colors.textPrimary}>
81
+ {isSelected ? "▸ " : " "}
82
+ </span>
83
+ <span
84
+ fg={isSelected ? colors.selectedText : colors.textPrimary}
85
+ attributes={isSelected ? "bold" : undefined}
86
+ >
87
+ {displayName}
88
+ </span>
89
+ {isCurrent && (
90
+ <span fg={colors.success}> (active)</span>
91
+ )}
92
+ </text>
93
+ </box>
94
+ );
95
+ })}
96
+
97
+ {/* Create new option */}
98
+ <box
99
+ style={{
100
+ paddingLeft: 1,
101
+ paddingRight: 1,
102
+ marginTop: databases.length > 0 ? 1 : 0,
103
+ backgroundColor: selectedIndex === databases.length ? colors.selectedRowBg : "transparent",
104
+ }}
105
+ >
106
+ <text>
107
+ <span fg={selectedIndex === databases.length ? colors.selectedText : colors.accent}>
108
+ {selectedIndex === databases.length ? "▸ " : " "}
109
+ </span>
110
+ <span
111
+ fg={selectedIndex === databases.length ? colors.selectedText : colors.accent}
112
+ attributes={selectedIndex === databases.length ? "bold" : undefined}
113
+ >
114
+ + Create new database...
115
+ </span>
116
+ </text>
117
+ </box>
118
+ </box>
119
+ <text fg={colors.textMuted} style={{ marginTop: 1 }}>
120
+ ↑/↓ to navigate, Enter to select, Esc to cancel
121
+ </text>
122
+ </Modal>
123
+ );
124
+ }
@@ -5,6 +5,8 @@ interface SettingsViewProps {
5
5
  settings: AppSettings;
6
6
  selectedIndex: number;
7
7
  theme: Theme;
8
+ currentDbFilename: string;
9
+ onSelectDatabase: () => void;
8
10
  onEditBusinessName: () => void;
9
11
  onEditStripeKey: () => void;
10
12
  onEditTimezone: () => void;
@@ -14,20 +16,30 @@ interface SettingsViewProps {
14
16
  onImportDatabase: () => void;
15
17
  }
16
18
 
17
- const SETTINGS_ITEMS = [
19
+ type SettingsItem = { key: string; label: string; type: string };
20
+
21
+ const BUSINESS_SETTINGS: SettingsItem[] = [
18
22
  { key: "businessName", label: "Business Name", type: "text" },
19
23
  { key: "stripeApiKey", label: "Stripe API Key", type: "secret" },
24
+ ];
25
+
26
+ const APP_SETTINGS: SettingsItem[] = [
27
+ { key: "database", label: "Database", type: "select" },
20
28
  { key: "theme", label: "Theme", type: "select" },
21
29
  { key: "timezone", label: "Timezone", type: "text" },
22
30
  { key: "menuBar", label: "Menu Bar", type: "toggle" },
23
31
  { key: "exportDatabase", label: "Export Database", type: "action" },
24
32
  { key: "importDatabase", label: "Import Database", type: "action" },
25
- ] as const;
33
+ ];
34
+
35
+ const ALL_SETTINGS = [...BUSINESS_SETTINGS, ...APP_SETTINGS];
26
36
 
27
37
  export function SettingsView({
28
38
  settings,
29
39
  selectedIndex,
30
40
  theme,
41
+ currentDbFilename,
42
+ onSelectDatabase,
31
43
  onEditBusinessName,
32
44
  onEditStripeKey,
33
45
  onEditTimezone,
@@ -58,6 +70,8 @@ export function SettingsView({
58
70
 
59
71
  const getValue = (key: string) => {
60
72
  switch (key) {
73
+ case "database":
74
+ return currentDbFilename.replace(/\.db$/, "");
61
75
  case "theme":
62
76
  return getThemeDisplay();
63
77
  case "businessName":
@@ -79,6 +93,8 @@ export function SettingsView({
79
93
 
80
94
  const getAction = (key: string) => {
81
95
  switch (key) {
96
+ case "database":
97
+ return onSelectDatabase;
82
98
  case "theme":
83
99
  return onSelectTheme;
84
100
  case "businessName":
@@ -105,6 +121,38 @@ export function SettingsView({
105
121
  return "edit";
106
122
  };
107
123
 
124
+ const renderItem = (item: SettingsItem, globalIndex: number) => {
125
+ const isSelected = globalIndex === selectedIndex;
126
+ return (
127
+ <box
128
+ key={item.key}
129
+ style={{
130
+ flexDirection: "row",
131
+ paddingLeft: 1,
132
+ paddingRight: 1,
133
+ backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
134
+ }}
135
+ >
136
+ <box style={{ width: 20 }}>
137
+ <text
138
+ fg={isSelected ? colors.selectedText : colors.textPrimary}
139
+ attributes={isSelected ? "bold" : undefined}
140
+ >
141
+ {item.label}
142
+ </text>
143
+ </box>
144
+ <box style={{ flexGrow: 1 }}>
145
+ <text fg={isSelected ? colors.selectedText : colors.textSecondary}>
146
+ {getValue(item.key)}
147
+ </text>
148
+ </box>
149
+ {isSelected && (
150
+ <text fg={colors.accent}>[Enter to {getActionLabel(item.type)}]</text>
151
+ )}
152
+ </box>
153
+ );
154
+ };
155
+
108
156
  return (
109
157
  <box
110
158
  style={{
@@ -113,50 +161,31 @@ export function SettingsView({
113
161
  padding: 1,
114
162
  }}
115
163
  >
116
- <box style={{ marginBottom: 1 }}>
117
- <text fg={colors.accent}>Application Settings</text>
164
+ <box
165
+ title="Business"
166
+ style={{
167
+ border: true,
168
+ borderColor: colors.borderSubtle,
169
+ padding: 1,
170
+ flexDirection: "column",
171
+ }}
172
+ >
173
+ {BUSINESS_SETTINGS.map((item, i) => renderItem(item, i))}
118
174
  </box>
119
175
 
120
176
  <box
121
- title="Settings"
177
+ title="App"
122
178
  style={{
123
179
  border: true,
124
180
  borderColor: colors.borderSubtle,
125
181
  padding: 1,
126
182
  flexDirection: "column",
183
+ marginTop: 1,
127
184
  }}
128
185
  >
129
- {SETTINGS_ITEMS.map((item, index) => {
130
- const isSelected = index === selectedIndex;
131
- return (
132
- <box
133
- key={item.key}
134
- style={{
135
- flexDirection: "row",
136
- paddingLeft: 1,
137
- paddingRight: 1,
138
- backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
139
- }}
140
- >
141
- <box style={{ width: 20 }}>
142
- <text
143
- fg={isSelected ? colors.selectedText : colors.textPrimary}
144
- attributes={isSelected ? "bold" : undefined}
145
- >
146
- {item.label}
147
- </text>
148
- </box>
149
- <box style={{ flexGrow: 1 }}>
150
- <text fg={isSelected ? colors.selectedText : colors.textSecondary}>
151
- {getValue(item.key)}
152
- </text>
153
- </box>
154
- {isSelected && (
155
- <text fg={colors.accent}>[Enter to {getActionLabel(item.type)}]</text>
156
- )}
157
- </box>
158
- );
159
- })}
186
+ {APP_SETTINGS.map((item, i) =>
187
+ renderItem(item, BUSINESS_SETTINGS.length + i),
188
+ )}
160
189
  </box>
161
190
 
162
191
  <box style={{ marginTop: 2 }}>
@@ -166,10 +195,10 @@ export function SettingsView({
166
195
  </box>
167
196
 
168
197
  <box style={{ marginTop: 1 }}>
169
- <text fg={colors.textMuted}>Database location: ~/.paca/paca.db</text>
198
+ <text fg={colors.textMuted}>Database: ~/.paca/{currentDbFilename}</text>
170
199
  </box>
171
200
  </box>
172
201
  );
173
202
  }
174
203
 
175
- export const SETTINGS_COUNT = SETTINGS_ITEMS.length;
204
+ export const SETTINGS_COUNT = ALL_SETTINGS.length;
@@ -15,3 +15,4 @@ export { CustomerSelectModal } from "./CustomerSelectModal.tsx";
15
15
  export { EditTimeEntryModal } from "./EditTimeEntryModal.tsx";
16
16
  export { CreateInvoiceModal } from "./CreateInvoiceModal.tsx";
17
17
  export { ThemeSelectModal } from "./ThemeSelectModal.tsx";
18
+ export { DatabaseSelectModal } from "./DatabaseSelectModal.tsx";
package/src/db-path.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, copyFileSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const ACTIVE_FILE = ".active";
6
+
7
+ export function getPacaDir(): string {
8
+ const dir = join(homedir(), ".paca");
9
+ if (!existsSync(dir)) {
10
+ mkdirSync(dir, { recursive: true });
11
+ }
12
+ return dir;
13
+ }
14
+
15
+ export function getActiveDbFilename(): string {
16
+ const activePath = join(getPacaDir(), ACTIVE_FILE);
17
+ try {
18
+ if (existsSync(activePath)) {
19
+ const filename = readFileSync(activePath, "utf-8").trim();
20
+ if (filename) return filename;
21
+ }
22
+ } catch {
23
+ // Fall back to default
24
+ }
25
+ return "paca.db";
26
+ }
27
+
28
+ export function getActiveDbPath(): string {
29
+ return join(getPacaDir(), getActiveDbFilename());
30
+ }
31
+
32
+ export function setActiveDbFilename(filename: string): void {
33
+ const activePath = join(getPacaDir(), ACTIVE_FILE);
34
+ writeFileSync(activePath, filename, "utf-8");
35
+ }
36
+
37
+ export function listDatabases(): string[] {
38
+ const dir = getPacaDir();
39
+ const active = getActiveDbFilename();
40
+ try {
41
+ const files = readdirSync(dir).filter((f) => f.endsWith(".db"));
42
+ if (!files.includes(active)) {
43
+ files.push(active);
44
+ }
45
+ return files.sort();
46
+ } catch {
47
+ return [active];
48
+ }
49
+ }
50
+
51
+ export function performDailyBackup(): void {
52
+ const dbFilename = getActiveDbFilename();
53
+ const dbPath = getActiveDbPath();
54
+ if (!existsSync(dbPath)) return;
55
+
56
+ const backupDir = join(getPacaDir(), "backups");
57
+ if (!existsSync(backupDir)) {
58
+ mkdirSync(backupDir, { recursive: true });
59
+ }
60
+
61
+ const dbName = dbFilename.replace(/\.db$/, "");
62
+ const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
63
+ const todayBackup = `${dbName}-auto-${today}.db`;
64
+
65
+ // Skip if today's backup already exists
66
+ if (existsSync(join(backupDir, todayBackup))) return;
67
+
68
+ // Create today's backup
69
+ copyFileSync(dbPath, join(backupDir, todayBackup));
70
+
71
+ // Clean up auto-backups older than 30 days
72
+ const cutoff = new Date();
73
+ cutoff.setDate(cutoff.getDate() - 30);
74
+ const prefix = `${dbName}-auto-`;
75
+
76
+ try {
77
+ for (const file of readdirSync(backupDir)) {
78
+ if (!file.startsWith(prefix) || !file.endsWith(".db")) continue;
79
+ const dateStr = file.slice(prefix.length, -3); // extract YYYY-MM-DD
80
+ const fileDate = new Date(dateStr);
81
+ if (!isNaN(fileDate.getTime()) && fileDate < cutoff) {
82
+ unlinkSync(join(backupDir, file));
83
+ }
84
+ }
85
+ } catch {
86
+ // Non-critical — skip cleanup on error
87
+ }
88
+ }
89
+
90
+ export function sanitizeDbName(name: string): string {
91
+ const sanitized = name
92
+ .toLowerCase()
93
+ .trim()
94
+ .replace(/[^a-z0-9]+/g, "-")
95
+ .replace(/^-+|-+$/g, "");
96
+ if (!sanitized) return "database.db";
97
+ return sanitized.endsWith(".db") ? sanitized : `${sanitized}.db`;
98
+ }
package/src/db.ts CHANGED
@@ -1,25 +1,27 @@
1
1
  import { PrismaClient } from "../generated/prisma/client.ts";
2
2
  import { PrismaLibSql } from "@prisma/adapter-libsql";
3
- import { existsSync, mkdirSync } from "fs";
4
- import { join } from "path";
5
- import { homedir } from "os";
6
-
7
- // Ensure the .paca directory exists in user's home
8
- const pacaDir = join(homedir(), ".paca");
9
- if (!existsSync(pacaDir)) {
10
- mkdirSync(pacaDir, { recursive: true });
11
- }
3
+ import { getActiveDbPath } from "./db-path.ts";
12
4
 
13
- // Database path
14
- export const DB_PATH = join(pacaDir, "paca.db");
5
+ // Get current database path (reads active db each time)
6
+ export function getDbPath(): string {
7
+ return getActiveDbPath();
8
+ }
15
9
 
16
- // Create adapter factory
17
- const adapterFactory = new PrismaLibSql({
18
- url: `file:${DB_PATH}`,
19
- });
10
+ // Create a new PrismaClient for a given database path
11
+ function createClient(dbPath: string): PrismaClient {
12
+ const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
13
+ return new PrismaClient({ adapter });
14
+ }
20
15
 
21
16
  // Create Prisma client with adapter
22
- export const db = new PrismaClient({ adapter: adapterFactory });
17
+ export let db = createClient(getDbPath());
18
+
19
+ // Switch to a different database
20
+ export async function switchDatabase(dbPath: string): Promise<void> {
21
+ await db.$disconnect();
22
+ db = createClient(dbPath);
23
+ await db.$connect();
24
+ }
23
25
 
24
26
  // Initialize database connection
25
27
  export async function initDatabase() {
@@ -738,7 +740,7 @@ export const invoices = {
738
740
  export const database = {
739
741
  async exportToFile(targetPath: string): Promise<void> {
740
742
  const { copyFileSync } = await import("fs");
741
- copyFileSync(DB_PATH, targetPath);
743
+ copyFileSync(getDbPath(), targetPath);
742
744
  },
743
745
 
744
746
  async importFromFile(sourcePath: string): Promise<void> {
@@ -748,7 +750,7 @@ export const database = {
748
750
  }
749
751
  // Close connection, copy file, reconnect
750
752
  await db.$disconnect();
751
- copyFileSync(sourcePath, DB_PATH);
753
+ copyFileSync(sourcePath, getDbPath());
752
754
  await db.$connect();
753
755
  },
754
756
  };
package/src/index.tsx CHANGED
@@ -6,8 +6,8 @@ import {
6
6
  initDatabase,
7
7
  closeDatabase,
8
8
  cleanupOldCompletedTasks,
9
- DB_PATH,
10
9
  } from "./db.ts";
10
+ import { getActiveDbPath } from "./db-path.ts";
11
11
  import { getTheme, type Theme } from "./types.ts";
12
12
  import { execSync } from "child_process";
13
13
  import { existsSync, readFileSync } from "fs";
@@ -19,10 +19,10 @@ let renderer: CliRenderer | null = null;
19
19
  // Try to read the theme setting from the database
20
20
  function getStoredTheme(): Theme {
21
21
  try {
22
- if (!existsSync(DB_PATH)) {
22
+ if (!existsSync(getActiveDbPath())) {
23
23
  return getTheme("catppuccin-mocha");
24
24
  }
25
- const db = new Database(DB_PATH, { readonly: true });
25
+ const db = new Database(getActiveDbPath(), { readonly: true });
26
26
  const result = db
27
27
  .query("SELECT value FROM Setting WHERE key = 'theme'")
28
28
  .get() as { value: string } | null;
@@ -156,7 +156,7 @@ async function main() {
156
156
  }
157
157
 
158
158
  // Check if database exists, if not run migration
159
- if (!existsSync(DB_PATH)) {
159
+ if (!existsSync(getActiveDbPath())) {
160
160
  console.log("Initializing Paca database...");
161
161
  try {
162
162
  // Run prisma migration
@@ -165,7 +165,7 @@ async function main() {
165
165
  stdio: "inherit",
166
166
  env: {
167
167
  ...process.env,
168
- DATABASE_URL: `file:${DB_PATH}`,
168
+ DATABASE_URL: `file:${getActiveDbPath()}`,
169
169
  },
170
170
  });
171
171
  console.log("Database initialized successfully!");
@@ -175,6 +175,14 @@ async function main() {
175
175
  }
176
176
  }
177
177
 
178
+ // Daily auto-backup (rolling 30 days)
179
+ try {
180
+ const { performDailyBackup } = await import("./db-path.ts");
181
+ performDailyBackup();
182
+ } catch {
183
+ // Non-critical — skip if backup fails
184
+ }
185
+
178
186
  // Initialize database connection
179
187
  const connected = await initDatabase();
180
188
  if (!connected) {
@@ -187,7 +195,7 @@ async function main() {
187
195
 
188
196
  // Auto-launch menu bar helper if enabled
189
197
  try {
190
- const db2 = new Database(DB_PATH, { readonly: true });
198
+ const db2 = new Database(getActiveDbPath(), { readonly: true });
191
199
  const menuBarSetting = db2
192
200
  .query("SELECT value FROM Setting WHERE key = 'menuBar'")
193
201
  .get() as { value: string } | null;
@@ -1,17 +1,14 @@
1
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");
2
+ import { getActiveDbPath } from "../db-path.ts";
6
3
 
7
4
  function openReadonly(): Database {
8
- const db = new Database(DB_PATH, { readonly: true });
5
+ const db = new Database(getActiveDbPath(), { readonly: true });
9
6
  db.exec("PRAGMA busy_timeout=5000");
10
7
  return db;
11
8
  }
12
9
 
13
10
  function openReadWrite(): Database {
14
- const db = new Database(DB_PATH);
11
+ const db = new Database(getActiveDbPath());
15
12
  db.exec("PRAGMA busy_timeout=5000");
16
13
  db.exec("PRAGMA journal_mode=WAL");
17
14
  return db;
@@ -23,7 +23,7 @@ function hasSwiftCompiler(): boolean {
23
23
  function prepareMascotIcon(): string {
24
24
  // Find the mascot image relative to source directory
25
25
  const srcDir = dirname(dirname(import.meta.dir));
26
- const mascotPath = join(srcDir, "assets", "paca-mascot.png");
26
+ const mascotPath = join(srcDir, "assets", "paca-icon.png");
27
27
 
28
28
  if (!existsSync(mascotPath)) {
29
29
  return "";
@@ -29,7 +29,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
29
29
 
30
30
  override init() {
31
31
  let home = FileManager.default.homeDirectoryForCurrentUser
32
- dbPath = home.appendingPathComponent(".paca/paca.db").path
32
+ let pacaDir = home.appendingPathComponent(".paca")
33
+ let activePath = pacaDir.appendingPathComponent(".active").path
34
+ var dbFilename = "paca.db"
35
+ if let activeContent = try? String(contentsOfFile: activePath, encoding: .utf8) {
36
+ let trimmed = activeContent.trimmingCharacters(in: .whitespacesAndNewlines)
37
+ if !trimmed.isEmpty { dbFilename = trimmed }
38
+ }
39
+ dbPath = pacaDir.appendingPathComponent(dbFilename).path
33
40
  super.init()
34
41
  }
35
42
 
@@ -43,9 +50,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
43
50
  }
44
51
  }
45
52
 
53
+ func resolveDbPath() -> String {
54
+ let home = FileManager.default.homeDirectoryForCurrentUser
55
+ let pacaDir = home.appendingPathComponent(".paca")
56
+ let activePath = pacaDir.appendingPathComponent(".active").path
57
+ var dbFilename = "paca.db"
58
+ if let activeContent = try? String(contentsOfFile: activePath, encoding: .utf8) {
59
+ let trimmed = activeContent.trimmingCharacters(in: .whitespacesAndNewlines)
60
+ if !trimmed.isEmpty { dbFilename = trimmed }
61
+ }
62
+ return pacaDir.appendingPathComponent(dbFilename).path
63
+ }
64
+
46
65
  func updateMenu() {
66
+ let currentDbPath = resolveDbPath()
47
67
  var db: OpaquePointer?
48
- guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
68
+ guard sqlite3_open_v2(currentDbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
49
69
  setIdleAppearance()
50
70
  statusItem.menu = buildFallbackMenu("Database not found")
51
71
  return
@@ -209,8 +229,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
209
229
 
210
230
  let desc = response == .alertFirstButtonReturn ? input.stringValue : nil
211
231
 
232
+ let currentDbPath = resolveDbPath()
212
233
  var db: OpaquePointer?
213
- guard sqlite3_open(dbPath, &db) == SQLITE_OK else { return }
234
+ guard sqlite3_open(currentDbPath, &db) == SQLITE_OK else { return }
214
235
  defer { sqlite3_close(db) }
215
236
  sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
216
237
  sqlite3_busy_timeout(db, 5000)
@@ -234,8 +255,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
234
255
  @objc func startTimerClicked(_ sender: NSMenuItem) {
235
256
  guard let projectId = sender.representedObject as? String else { return }
236
257
 
258
+ let currentDbPath = resolveDbPath()
237
259
  var db: OpaquePointer?
238
- guard sqlite3_open(dbPath, &db) == SQLITE_OK else { return }
260
+ guard sqlite3_open(currentDbPath, &db) == SQLITE_OK else { return }
239
261
  defer { sqlite3_close(db) }
240
262
  sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
241
263
  sqlite3_busy_timeout(db, 5000)
package/src/types.ts CHANGED
@@ -69,7 +69,9 @@ export type InputMode =
69
69
  | "confirm_delete_time_entry"
70
70
  | "create_customer"
71
71
  | "edit_customer"
72
- | "select_customer";
72
+ | "select_customer"
73
+ | "select_database"
74
+ | "create_database_name";
73
75
 
74
76
  export interface RunningTimer {
75
77
  id: string;