pacatui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/generated/prisma/browser.ts +59 -0
  4. package/generated/prisma/client.ts +81 -0
  5. package/generated/prisma/commonInputTypes.ts +402 -0
  6. package/generated/prisma/enums.ts +15 -0
  7. package/generated/prisma/internal/class.ts +260 -0
  8. package/generated/prisma/internal/prismaNamespace.ts +1362 -0
  9. package/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  10. package/generated/prisma/models/Customer.ts +1489 -0
  11. package/generated/prisma/models/Invoice.ts +1837 -0
  12. package/generated/prisma/models/Project.ts +1981 -0
  13. package/generated/prisma/models/Setting.ts +1086 -0
  14. package/generated/prisma/models/Tag.ts +1288 -0
  15. package/generated/prisma/models/Task.ts +1669 -0
  16. package/generated/prisma/models/TaskTag.ts +1340 -0
  17. package/generated/prisma/models/TimeEntry.ts +1602 -0
  18. package/generated/prisma/models.ts +19 -0
  19. package/package.json +71 -0
  20. package/prisma/migrations/20260115051911_init/migration.sql +71 -0
  21. package/prisma/migrations/20260115062427_add_time_tracking/migration.sql +20 -0
  22. package/prisma/migrations/20260117233250_add_customers_invoices/migration.sql +81 -0
  23. package/prisma/migrations/migration_lock.toml +3 -0
  24. package/prisma/schema.prisma +162 -0
  25. package/src/App.tsx +1492 -0
  26. package/src/components/CreateInvoiceModal.tsx +222 -0
  27. package/src/components/CustomerModal.tsx +158 -0
  28. package/src/components/CustomerSelectModal.tsx +142 -0
  29. package/src/components/Dashboard.tsx +242 -0
  30. package/src/components/DateTimePicker.tsx +335 -0
  31. package/src/components/EditTimeEntryModal.tsx +293 -0
  32. package/src/components/Header.tsx +65 -0
  33. package/src/components/HelpView.tsx +109 -0
  34. package/src/components/InputModal.tsx +79 -0
  35. package/src/components/InvoicesView.tsx +297 -0
  36. package/src/components/Modal.tsx +38 -0
  37. package/src/components/ProjectList.tsx +114 -0
  38. package/src/components/ProjectModal.tsx +116 -0
  39. package/src/components/SettingsView.tsx +145 -0
  40. package/src/components/SplashScreen.tsx +25 -0
  41. package/src/components/StatusBar.tsx +93 -0
  42. package/src/components/TaskList.tsx +143 -0
  43. package/src/components/Timer.tsx +95 -0
  44. package/src/components/TimerModals.tsx +120 -0
  45. package/src/components/TimesheetView.tsx +218 -0
  46. package/src/components/index.ts +17 -0
  47. package/src/db.ts +629 -0
  48. package/src/hooks/usePaste.ts +69 -0
  49. package/src/index.tsx +75 -0
  50. package/src/stripe.ts +163 -0
  51. package/src/types.ts +361 -0
package/src/index.tsx ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bun
2
+ import { createCliRenderer, type CliRenderer } from "@opentui/core";
3
+ import { createRoot } from "@opentui/react";
4
+ import { App } from "./App.tsx";
5
+ import { initDatabase, closeDatabase, cleanupOldCompletedTasks, DB_PATH } from "./db.ts";
6
+ import { execSync } from "child_process";
7
+ import { existsSync } from "fs";
8
+ import { dirname } from "path";
9
+
10
+ let renderer: CliRenderer | null = null;
11
+
12
+ async function main() {
13
+ // Check if database exists, if not run migration
14
+ if (!existsSync(DB_PATH)) {
15
+ console.log("Initializing Paca database...");
16
+ try {
17
+ // Run prisma migration
18
+ const projectDir = dirname(dirname(import.meta.path));
19
+ execSync(`cd "${projectDir}" && bunx prisma migrate deploy`, {
20
+ stdio: "inherit",
21
+ env: {
22
+ ...process.env,
23
+ DATABASE_URL: `file:${DB_PATH}`,
24
+ },
25
+ });
26
+ console.log("Database initialized successfully!");
27
+ } catch (error) {
28
+ console.error("Failed to initialize database:", error);
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ // Initialize database connection
34
+ const connected = await initDatabase();
35
+ if (!connected) {
36
+ console.error("Failed to connect to database");
37
+ process.exit(1);
38
+ }
39
+
40
+ // Clean up tasks that have been done for more than 3 days
41
+ await cleanupOldCompletedTasks(3);
42
+
43
+ // Handle cleanup on exit - must stop renderer to restore terminal state
44
+ const cleanup = async () => {
45
+ if (renderer) {
46
+ renderer.stop();
47
+ }
48
+ await closeDatabase();
49
+ // Give renderer time to finish, then reset terminal and exit
50
+ setTimeout(() => {
51
+ try {
52
+ require("child_process").spawnSync("reset", [], { stdio: "inherit" });
53
+ } catch {}
54
+ process.exit(0);
55
+ }, 50);
56
+ };
57
+
58
+ process.on("SIGINT", cleanup);
59
+ process.on("SIGTERM", cleanup);
60
+
61
+ // Create and start the TUI
62
+ try {
63
+ renderer = await createCliRenderer({
64
+ exitOnCtrlC: false,
65
+ });
66
+
67
+ createRoot(renderer).render(<App />);
68
+ } catch (error) {
69
+ console.error("Failed to start Paca:", error);
70
+ await closeDatabase();
71
+ process.exit(1);
72
+ }
73
+ }
74
+
75
+ main();
package/src/stripe.ts ADDED
@@ -0,0 +1,163 @@
1
+ import Stripe from "stripe";
2
+
3
+ // Get or create Stripe customer, returns Stripe customer ID
4
+ export async function getOrCreateStripeCustomer(
5
+ apiKey: string,
6
+ customer: { name: string; email: string; stripeCustomerId: string | null },
7
+ ): Promise<string> {
8
+ const stripe = new Stripe(apiKey);
9
+
10
+ // If we already have a Stripe customer ID, verify it exists
11
+ if (customer.stripeCustomerId) {
12
+ try {
13
+ await stripe.customers.retrieve(customer.stripeCustomerId);
14
+ return customer.stripeCustomerId;
15
+ } catch {
16
+ // Customer was deleted in Stripe, create new one
17
+ }
18
+ }
19
+
20
+ // Search by email first
21
+ const existing = await stripe.customers.list({ email: customer.email, limit: 1 });
22
+ if (existing.data[0]) {
23
+ return existing.data[0].id;
24
+ }
25
+
26
+ // Create new customer
27
+ const newCustomer = await stripe.customers.create({
28
+ name: customer.name,
29
+ email: customer.email,
30
+ });
31
+ return newCustomer.id;
32
+ }
33
+
34
+ // Line item for invoice creation
35
+ export interface InvoiceLineItem {
36
+ description: string;
37
+ hours: number;
38
+ rate: number;
39
+ startTime: Date;
40
+ endTime: Date;
41
+ }
42
+
43
+ // Create draft invoice with line items
44
+ export async function createDraftInvoice(
45
+ apiKey: string,
46
+ stripeCustomerId: string,
47
+ projectName: string,
48
+ lineItems: InvoiceLineItem[],
49
+ ): Promise<string> {
50
+ const stripe = new Stripe(apiKey);
51
+
52
+ // Create pending invoice items first (they'll be collected into the invoice)
53
+ for (const item of lineItems) {
54
+ const amount = Math.round(item.hours * item.rate * 100); // Stripe uses cents
55
+ if (amount <= 0) continue; // Skip zero-amount items
56
+
57
+ // Format hours with 2 decimal places
58
+ const hoursFormatted = item.hours.toFixed(2);
59
+
60
+ // Format start and end times
61
+ const formatTime = (date: Date) => date.toLocaleString("en-US", {
62
+ month: "short",
63
+ day: "numeric",
64
+ hour: "numeric",
65
+ minute: "2-digit",
66
+ hour12: true,
67
+ });
68
+
69
+ const timeRange = `${formatTime(item.startTime)} - ${formatTime(item.endTime)}`;
70
+
71
+ // Format: "4.75 hour(s) :: Project Name :: Description"
72
+ const description = `${hoursFormatted} hour(s) :: ${projectName} :: ${item.description}`;
73
+
74
+ await stripe.invoiceItems.create({
75
+ customer: stripeCustomerId,
76
+ amount,
77
+ currency: "usd",
78
+ description,
79
+ metadata: {
80
+ project: projectName,
81
+ start_time: item.startTime.toISOString(),
82
+ end_time: item.endTime.toISOString(),
83
+ time_range: timeRange,
84
+ hours: hoursFormatted,
85
+ },
86
+ });
87
+ }
88
+
89
+ // Create invoice - this will automatically include all pending invoice items
90
+ const invoice = await stripe.invoices.create({
91
+ customer: stripeCustomerId,
92
+ auto_advance: false, // Keep as draft
93
+ collection_method: "send_invoice",
94
+ days_until_due: 30,
95
+ pending_invoice_items_behavior: "include",
96
+ });
97
+
98
+ return invoice.id;
99
+ }
100
+
101
+ // Invoice display type
102
+ export interface StripeInvoiceItem {
103
+ id: string;
104
+ number: string | null;
105
+ customerName: string | null;
106
+ customerEmail: string | null;
107
+ status: string;
108
+ amount: number;
109
+ currency: string;
110
+ created: Date;
111
+ dueDate: Date | null;
112
+ hostedUrl: string | null;
113
+ dashboardUrl: string;
114
+ }
115
+
116
+ export interface ListInvoicesResult {
117
+ invoices: StripeInvoiceItem[];
118
+ hasMore: boolean;
119
+ nextCursor: string | null;
120
+ }
121
+
122
+ // List invoices from Stripe with pagination
123
+ export async function listInvoices(
124
+ apiKey: string,
125
+ limit = 25,
126
+ startingAfter?: string,
127
+ ): Promise<ListInvoicesResult> {
128
+ const stripe = new Stripe(apiKey);
129
+
130
+ const params: Stripe.InvoiceListParams = {
131
+ limit,
132
+ expand: ["data.customer"],
133
+ };
134
+
135
+ if (startingAfter) {
136
+ params.starting_after = startingAfter;
137
+ }
138
+
139
+ const response = await stripe.invoices.list(params);
140
+
141
+ const invoices: StripeInvoiceItem[] = response.data.map((inv) => {
142
+ const customer = inv.customer as Stripe.Customer | null;
143
+ return {
144
+ id: inv.id,
145
+ number: inv.number,
146
+ customerName: customer?.name ?? null,
147
+ customerEmail: customer?.email ?? null,
148
+ status: inv.status ?? "unknown",
149
+ amount: inv.amount_due / 100, // Convert from cents
150
+ currency: inv.currency,
151
+ created: new Date(inv.created * 1000),
152
+ dueDate: inv.due_date ? new Date(inv.due_date * 1000) : null,
153
+ hostedUrl: inv.hosted_invoice_url ?? null,
154
+ dashboardUrl: `https://dashboard.stripe.com/invoices/${inv.id}`,
155
+ };
156
+ });
157
+
158
+ return {
159
+ invoices,
160
+ hasMore: response.has_more,
161
+ nextCursor: response.data.length > 0 ? response.data[response.data.length - 1]?.id ?? null : null,
162
+ };
163
+ }
package/src/types.ts ADDED
@@ -0,0 +1,361 @@
1
+ import type {
2
+ Project,
3
+ Task,
4
+ Tag,
5
+ TimeEntry,
6
+ Customer,
7
+ Invoice,
8
+ } from "../generated/prisma/client.ts";
9
+
10
+ export type { Project, Task, Tag, TimeEntry, Customer, Invoice };
11
+
12
+ export type TaskStatus = "todo" | "in_progress" | "done";
13
+ export type TaskPriority = "low" | "medium" | "high" | "urgent";
14
+
15
+ export interface ProjectWithTasks extends Project {
16
+ tasks: Task[];
17
+ }
18
+
19
+ export interface ProjectWithTaskCounts extends Project {
20
+ tasks: { id: string; status: string }[];
21
+ }
22
+
23
+ export interface TaskWithTags extends Task {
24
+ tags: { tag: Tag }[];
25
+ }
26
+
27
+ export interface TaskWithProject extends Task {
28
+ project: Project;
29
+ tags: { tag: Tag }[];
30
+ }
31
+
32
+ export type View =
33
+ | "dashboard"
34
+ | "projects"
35
+ | "tasks"
36
+ | "settings"
37
+ | "help"
38
+ | "timesheets"
39
+ | "invoices";
40
+
41
+ export type Panel = "projects" | "tasks" | "details";
42
+
43
+ export interface AppState {
44
+ currentView: View;
45
+ activePanel: Panel;
46
+ selectedProjectId: string | null;
47
+ selectedTaskId: string | null;
48
+ showArchived: boolean;
49
+ inputMode: InputMode | null;
50
+ }
51
+
52
+ export type InputMode =
53
+ | "create_project"
54
+ | "edit_project"
55
+ | "create_task"
56
+ | "edit_task"
57
+ | "edit_dashboard_task"
58
+ | "confirm_delete"
59
+ | "stop_timer"
60
+ | "select_timer_project"
61
+ | "search"
62
+ | "edit_business_name"
63
+ | "edit_stripe_key"
64
+ | "edit_timezone"
65
+ | "confirm_import"
66
+ | "edit_time_entry"
67
+ | "create_invoice"
68
+ | "confirm_delete_time_entry"
69
+ | "create_customer"
70
+ | "edit_customer"
71
+ | "select_customer";
72
+
73
+ export interface RunningTimer {
74
+ id: string;
75
+ startTime: Date;
76
+ project: {
77
+ id: string;
78
+ name: string;
79
+ color: string;
80
+ hourlyRate: number | null;
81
+ };
82
+ }
83
+
84
+ export interface TimeEntryWithProject extends TimeEntry {
85
+ project: {
86
+ id: string;
87
+ name: string;
88
+ color: string;
89
+ hourlyRate: number | null;
90
+ };
91
+ }
92
+
93
+ export interface TimeStats {
94
+ todayMs: number;
95
+ weekMs: number;
96
+ monthMs: number;
97
+ }
98
+
99
+ export interface AppSettings {
100
+ businessName: string;
101
+ stripeApiKey: string;
102
+ timezone: string; // IANA timezone (e.g., "America/New_York") or "auto" for system detection
103
+ }
104
+
105
+ // Get the system's detected timezone
106
+ export function getSystemTimezone(): string {
107
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
108
+ }
109
+
110
+ // Get the effective timezone (resolves "auto" to system timezone)
111
+ export function getEffectiveTimezone(settings: AppSettings): string {
112
+ if (!settings.timezone || settings.timezone === "auto") {
113
+ return getSystemTimezone();
114
+ }
115
+ return settings.timezone;
116
+ }
117
+
118
+ // Format a date for display in the given timezone
119
+ export function formatDateInTimezone(
120
+ date: Date | string,
121
+ timezone: string,
122
+ ): string {
123
+ if (!date) return "";
124
+ const dateObj = date instanceof Date ? date : new Date(date);
125
+ if (isNaN(dateObj.getTime())) return "";
126
+
127
+ try {
128
+ return dateObj.toLocaleDateString("en-US", {
129
+ timeZone: timezone,
130
+ month: "short",
131
+ day: "numeric",
132
+ });
133
+ } catch {
134
+ return dateObj.toLocaleDateString("en-US", {
135
+ month: "short",
136
+ day: "numeric",
137
+ });
138
+ }
139
+ }
140
+
141
+ // Format a time for display in the given timezone
142
+ export function formatTimeInTimezone(
143
+ date: Date | string,
144
+ timezone: string,
145
+ ): string {
146
+ if (!date) return "";
147
+ const dateObj = date instanceof Date ? date : new Date(date);
148
+ if (isNaN(dateObj.getTime())) return "";
149
+
150
+ try {
151
+ return dateObj.toLocaleTimeString("en-US", {
152
+ timeZone: timezone,
153
+ hour: "numeric",
154
+ minute: "2-digit",
155
+ hour12: true,
156
+ });
157
+ } catch {
158
+ return dateObj.toLocaleTimeString("en-US", {
159
+ hour: "numeric",
160
+ minute: "2-digit",
161
+ hour12: true,
162
+ });
163
+ }
164
+ }
165
+
166
+ // Format a date-time for editing (YYYY-MM-DD HH:MM) in the given timezone
167
+ export function formatDateTimeForEdit(
168
+ date: Date | string,
169
+ timezone: string,
170
+ ): string {
171
+ if (!date) return "";
172
+
173
+ const dateObj = date instanceof Date ? date : new Date(date);
174
+ if (isNaN(dateObj.getTime())) return "";
175
+
176
+ try {
177
+ const formatter = new Intl.DateTimeFormat("en-CA", {
178
+ timeZone: timezone,
179
+ year: "numeric",
180
+ month: "2-digit",
181
+ day: "2-digit",
182
+ hour: "2-digit",
183
+ minute: "2-digit",
184
+ hour12: false,
185
+ });
186
+ const parts = formatter.formatToParts(dateObj);
187
+ const get = (type: string) =>
188
+ parts.find((p) => p.type === type)?.value || "";
189
+ return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}`;
190
+ } catch {
191
+ // Fallback to local time if timezone is invalid
192
+ const year = dateObj.getFullYear();
193
+ const month = String(dateObj.getMonth() + 1).padStart(2, "0");
194
+ const day = String(dateObj.getDate()).padStart(2, "0");
195
+ const hours = String(dateObj.getHours()).padStart(2, "0");
196
+ const minutes = String(dateObj.getMinutes()).padStart(2, "0");
197
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
198
+ }
199
+ }
200
+
201
+ // Parse a date-time string (YYYY-MM-DD HH:MM) in the given timezone
202
+ export function parseDateTimeInTimezone(
203
+ input: string,
204
+ timezone: string,
205
+ ): Date | null {
206
+ const match = input.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})$/);
207
+ if (!match || !match[1] || !match[2] || !match[3] || !match[4] || !match[5]) {
208
+ return null;
209
+ }
210
+
211
+ const [, year, month, day, hour, minute] = match;
212
+ const dateStr = `${year}-${month}-${day}T${hour.padStart(2, "0")}:${minute}:00`;
213
+
214
+ try {
215
+ // Use Intl to get the offset for this date in the target timezone
216
+ const testDate = new Date(dateStr + "Z"); // Parse as UTC first
217
+ const formatter = new Intl.DateTimeFormat("en-US", {
218
+ timeZone: timezone,
219
+ timeZoneName: "shortOffset",
220
+ });
221
+
222
+ // Get parts to find the offset
223
+ const parts = formatter.formatToParts(testDate);
224
+ const offsetPart =
225
+ parts.find((p) => p.type === "timeZoneName")?.value || "";
226
+
227
+ // Parse offset like "GMT-5" or "GMT+5:30"
228
+ const offsetMatch = offsetPart.match(/GMT([+-]?)(\d+)(?::(\d+))?/);
229
+ let offsetMinutes = 0;
230
+ if (offsetMatch) {
231
+ const sign = offsetMatch[1] === "-" ? -1 : 1;
232
+ const hours = parseInt(offsetMatch[2] || "0", 10);
233
+ const minutes = parseInt(offsetMatch[3] || "0", 10);
234
+ offsetMinutes = sign * (hours * 60 + minutes);
235
+ }
236
+
237
+ // Create the date by adjusting for the timezone offset
238
+ const utcDate = new Date(dateStr + "Z");
239
+ utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
240
+
241
+ if (isNaN(utcDate.getTime())) {
242
+ return null;
243
+ }
244
+ return utcDate;
245
+ } catch {
246
+ // Fallback: parse as local time if timezone is invalid
247
+ const localDate = new Date(
248
+ parseInt(year),
249
+ parseInt(month) - 1,
250
+ parseInt(day),
251
+ parseInt(hour),
252
+ parseInt(minute),
253
+ );
254
+ if (isNaN(localDate.getTime())) {
255
+ return null;
256
+ }
257
+ return localDate;
258
+ }
259
+ }
260
+
261
+ export interface DashboardStats {
262
+ totalProjects: number;
263
+ activeProjects: number;
264
+ archivedProjects: number;
265
+ totalTasks: number;
266
+ todoTasks: number;
267
+ inProgressTasks: number;
268
+ doneTasks: number;
269
+ overdueTasks: number;
270
+ completionRate: number;
271
+ }
272
+
273
+ export interface TimesheetGroup {
274
+ project: {
275
+ id: string;
276
+ name: string;
277
+ color: string;
278
+ hourlyRate: number | null;
279
+ customer: Customer | null;
280
+ };
281
+ entries: TimeEntryWithProject[];
282
+ totalMs: number;
283
+ totalAmount: number;
284
+ }
285
+
286
+ export interface ProjectWithCustomer extends Project {
287
+ customer: Customer | null;
288
+ }
289
+
290
+ export interface TimeEntryWithInvoice extends TimeEntry {
291
+ invoiceId: string | null;
292
+ project: {
293
+ id: string;
294
+ name: string;
295
+ color: string;
296
+ hourlyRate: number | null;
297
+ customer: Customer | null;
298
+ };
299
+ }
300
+
301
+ export const STATUS_LABELS: Record<TaskStatus, string> = {
302
+ todo: "To Do",
303
+ in_progress: "In Progress",
304
+ done: "Done",
305
+ };
306
+
307
+ export const STATUS_ICONS: Record<TaskStatus, string> = {
308
+ todo: "○",
309
+ in_progress: "◐",
310
+ done: "●",
311
+ };
312
+
313
+ export const PRIORITY_LABELS: Record<TaskPriority, string> = {
314
+ low: "Low",
315
+ medium: "Medium",
316
+ high: "High",
317
+ urgent: "Urgent",
318
+ };
319
+
320
+ export const PRIORITY_COLORS: Record<TaskPriority, string> = {
321
+ low: "gray",
322
+ medium: "blue",
323
+ high: "yellow",
324
+ urgent: "red",
325
+ };
326
+
327
+ export const PROJECT_COLORS = [
328
+ "#3b82f6", // Blue
329
+ "#10b981", // Green
330
+ "#f59e0b", // Amber
331
+ "#ef4444", // Red
332
+ "#8b5cf6", // Purple
333
+ "#ec4899", // Pink
334
+ "#06b6d4", // Cyan
335
+ "#f97316", // Orange
336
+ ];
337
+
338
+ export const COLORS = {
339
+ bg: "#171623",
340
+ border: "#777777",
341
+ borderOff: "#444444",
342
+ selectedRowBg: "#3325b4",
343
+ textPrimary: "#e0e0e0",
344
+ textSecondary: "#94a3b8",
345
+ accent: "#3b82f6",
346
+ };
347
+
348
+ // Stripe invoice data for display
349
+ export interface StripeInvoiceDisplay {
350
+ id: string;
351
+ number: string | null;
352
+ customerName: string | null;
353
+ customerEmail: string | null;
354
+ status: string;
355
+ amount: number;
356
+ currency: string;
357
+ created: Date;
358
+ dueDate: Date | null;
359
+ hostedUrl: string | null;
360
+ pdfUrl: string | null;
361
+ }