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
@@ -0,0 +1,242 @@
1
+ import { useTerminalDimensions } from "@opentui/react";
2
+ import type { DashboardStats, Task } from "../types.ts";
3
+ import { COLORS } from "../types.ts";
4
+
5
+ interface DashboardProps {
6
+ stats: DashboardStats;
7
+ recentTasks: (Task & { project: { name: string; color: string } })[];
8
+ selectedIndex: number;
9
+ focused: boolean;
10
+ }
11
+
12
+ const STATUS_COLORS = {
13
+ todo: "#64748b",
14
+ inProgress: "#f59e0b",
15
+ done: "#10b981",
16
+ };
17
+
18
+ function StackedBarChart({
19
+ stats,
20
+ width,
21
+ }: {
22
+ stats: DashboardStats;
23
+ width: number;
24
+ }) {
25
+ const total = stats.totalTasks;
26
+ const barWidth = Math.max(width - 4, 20); // Account for padding/borders
27
+
28
+ if (total === 0) {
29
+ return (
30
+ <box style={{ flexDirection: "column", gap: 1, width: "100%" }}>
31
+ <text fg="#334155">{"░".repeat(barWidth)}</text>
32
+ <text fg="#334155">{"░".repeat(barWidth)}</text>
33
+ <text fg="#64748b">No tasks yet</text>
34
+ </box>
35
+ );
36
+ }
37
+
38
+ // Calculate widths for each segment
39
+ const doneWidth = Math.round((stats.doneTasks / total) * barWidth);
40
+ const inProgressWidth = Math.round(
41
+ (stats.inProgressTasks / total) * barWidth,
42
+ );
43
+ const todoWidth = barWidth - doneWidth - inProgressWidth;
44
+
45
+ // Build the bar string
46
+ const barLine =
47
+ "█".repeat(doneWidth) + "█".repeat(inProgressWidth) + "█".repeat(todoWidth);
48
+
49
+ return (
50
+ <box style={{ flexDirection: "column", gap: 1, width: "100%" }}>
51
+ {/* The stacked bar - multiple lines for height */}
52
+ <text>
53
+ <span fg={STATUS_COLORS.done}>{"█".repeat(doneWidth)}</span>
54
+ <span fg={STATUS_COLORS.inProgress}>{"█".repeat(inProgressWidth)}</span>
55
+ <span fg={STATUS_COLORS.todo}>{"█".repeat(todoWidth)}</span>
56
+ </text>
57
+ <text>
58
+ <span fg={STATUS_COLORS.done}>{"█".repeat(doneWidth)}</span>
59
+ <span fg={STATUS_COLORS.inProgress}>{"█".repeat(inProgressWidth)}</span>
60
+ <span fg={STATUS_COLORS.todo}>{"█".repeat(todoWidth)}</span>
61
+ </text>
62
+
63
+ {/* Legend */}
64
+ <box style={{ flexDirection: "row", gap: 3, marginTop: 1 }}>
65
+ <text>
66
+ <span fg={STATUS_COLORS.done}>●</span>
67
+ <span fg="#94a3b8"> Done </span>
68
+ <span fg="#ffffff" attributes="bold">
69
+ {stats.doneTasks}
70
+ </span>
71
+ </text>
72
+ <text>
73
+ <span fg={STATUS_COLORS.inProgress}>●</span>
74
+ <span fg="#94a3b8"> In Progress </span>
75
+ <span fg="#ffffff" attributes="bold">
76
+ {stats.inProgressTasks}
77
+ </span>
78
+ </text>
79
+ <text>
80
+ <span fg={STATUS_COLORS.todo}>●</span>
81
+ <span fg="#94a3b8"> To Do </span>
82
+ <span fg="#ffffff" attributes="bold">
83
+ {stats.todoTasks}
84
+ </span>
85
+ </text>
86
+ </box>
87
+ </box>
88
+ );
89
+ }
90
+
91
+ export function Dashboard({
92
+ stats,
93
+ recentTasks,
94
+ selectedIndex,
95
+ focused,
96
+ }: DashboardProps) {
97
+ const { width: termWidth } = useTerminalDimensions();
98
+
99
+ const getStatusColor = (status: string) => {
100
+ switch (status) {
101
+ case "done":
102
+ return "#10b981";
103
+ case "in_progress":
104
+ return "#f59e0b";
105
+ default:
106
+ return "#64748b";
107
+ }
108
+ };
109
+
110
+ const getPriorityIndicator = (
111
+ priority: string,
112
+ ): { symbol: string; color: string } => {
113
+ const indicators: Record<string, { symbol: string; color: string }> = {
114
+ urgent: { symbol: "!", color: "#ef4444" },
115
+ high: { symbol: "!", color: "#f59e0b" },
116
+ medium: { symbol: "!", color: "#3b82f6" },
117
+ low: { symbol: " ", color: "#64748b" },
118
+ };
119
+ return indicators[priority] ?? { symbol: "!", color: "#3b82f6" };
120
+ };
121
+
122
+ return (
123
+ <box
124
+ style={{
125
+ flexDirection: "column",
126
+ flexGrow: 1,
127
+ padding: 1,
128
+ gap: 1,
129
+ }}
130
+ >
131
+ {/* Progress Section */}
132
+ <box
133
+ title="Task Status"
134
+ style={{
135
+ border: true,
136
+ borderColor: "#334155",
137
+ padding: 1,
138
+ flexDirection: "column",
139
+ width: "100%",
140
+ }}
141
+ >
142
+ <StackedBarChart stats={stats} width={termWidth} />
143
+ </box>
144
+
145
+ {/* Recent Activity */}
146
+ <box
147
+ title="Recent Activity"
148
+ style={{
149
+ border: true,
150
+ borderColor: COLORS.borderOff,
151
+ flexGrow: 1,
152
+ flexDirection: "column",
153
+ }}
154
+ >
155
+ {recentTasks.length === 0 ? (
156
+ <box
157
+ style={{
158
+ flexGrow: 1,
159
+ alignItems: "center",
160
+ justifyContent: "center",
161
+ }}
162
+ >
163
+ <text fg="#64748b">No recent activity</text>
164
+ <text fg="#475569">Create a project to get started!</text>
165
+ </box>
166
+ ) : (
167
+ <scrollbox focused={focused} style={{ flexGrow: 1 }}>
168
+ {recentTasks.map((task, index) => {
169
+ const isSelected = index === selectedIndex;
170
+ const priority = getPriorityIndicator(task.priority);
171
+
172
+ return (
173
+ <box
174
+ key={task.id}
175
+ style={{
176
+ width: "100%",
177
+ paddingLeft: 1,
178
+ paddingRight: 1,
179
+ backgroundColor:
180
+ isSelected && focused
181
+ ? COLORS.selectedRowBg
182
+ : "transparent",
183
+ }}
184
+ >
185
+ <box
186
+ style={{
187
+ flexDirection: "row",
188
+ justifyContent: "space-between",
189
+ }}
190
+ >
191
+ <box style={{ width: 2 }}>
192
+ <text fg={getStatusColor(task.status)}>
193
+ {task.status === "done"
194
+ ? "●"
195
+ : task.status === "in_progress"
196
+ ? "◐"
197
+ : "○"}{" "}
198
+ </text>
199
+ </box>
200
+ <box style={{ width: 2 }}>
201
+ <text fg={priority.color}>{priority.symbol}</text>
202
+ </box>
203
+ <box style={{ flexGrow: 1 }}>
204
+ <text>
205
+ <span
206
+ fg={
207
+ task.status === "done"
208
+ ? "#64748b"
209
+ : isSelected
210
+ ? "#ffffff"
211
+ : "#e2e8f0"
212
+ }
213
+ attributes={
214
+ task.status === "done"
215
+ ? "strikethrough"
216
+ : isSelected
217
+ ? "bold"
218
+ : undefined
219
+ }
220
+ >
221
+ {task.title}
222
+ </span>
223
+ <span fg="#64748b"> in </span>
224
+ <span fg={task.project.color}>{task.project.name}</span>
225
+ </text>
226
+ </box>
227
+ <text fg="#475569">
228
+ {new Date(task.updatedAt).toLocaleDateString("en-US", {
229
+ month: "short",
230
+ day: "numeric",
231
+ })}
232
+ </text>
233
+ </box>
234
+ </box>
235
+ );
236
+ })}
237
+ </scrollbox>
238
+ )}
239
+ </box>
240
+ </box>
241
+ );
242
+ }
@@ -0,0 +1,335 @@
1
+ import { useState, useEffect } from "react";
2
+ import { COLORS } from "../types";
3
+
4
+ interface DateTimePickerProps {
5
+ value: Date;
6
+ timezone: string;
7
+ focused: boolean;
8
+ onChange: (date: Date) => void;
9
+ }
10
+
11
+ type Field = "month" | "day" | "year" | "hour" | "minute" | "ampm";
12
+
13
+ const MONTHS = [
14
+ "Jan",
15
+ "Feb",
16
+ "Mar",
17
+ "Apr",
18
+ "May",
19
+ "Jun",
20
+ "Jul",
21
+ "Aug",
22
+ "Sep",
23
+ "Oct",
24
+ "Nov",
25
+ "Dec",
26
+ ];
27
+
28
+ function getDatePartsInTimezone(
29
+ date: Date,
30
+ timezone: string,
31
+ ): {
32
+ year: number;
33
+ month: number;
34
+ day: number;
35
+ hour: number;
36
+ minute: number;
37
+ hour12: number;
38
+ ampm: "AM" | "PM";
39
+ } {
40
+ try {
41
+ const formatter = new Intl.DateTimeFormat("en-US", {
42
+ timeZone: timezone,
43
+ year: "numeric",
44
+ month: "numeric",
45
+ day: "numeric",
46
+ hour: "numeric",
47
+ minute: "numeric",
48
+ hour12: true,
49
+ });
50
+ const parts = formatter.formatToParts(date);
51
+ const get = (type: string) =>
52
+ parts.find((p) => p.type === type)?.value || "";
53
+
54
+ const hour24 = parseInt(get("hour"));
55
+ const ampm = get("dayPeriod") as "AM" | "PM";
56
+
57
+ return {
58
+ year: parseInt(get("year")),
59
+ month: parseInt(get("month")) - 1,
60
+ day: parseInt(get("day")),
61
+ hour: hour24,
62
+ minute: parseInt(get("minute")),
63
+ hour12: hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24,
64
+ ampm: ampm || (hour24 >= 12 ? "PM" : "AM"),
65
+ };
66
+ } catch {
67
+ // Fallback to local time
68
+ const hour24 = date.getHours();
69
+ return {
70
+ year: date.getFullYear(),
71
+ month: date.getMonth(),
72
+ day: date.getDate(),
73
+ hour: hour24,
74
+ minute: date.getMinutes(),
75
+ hour12: hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24,
76
+ ampm: hour24 >= 12 ? "PM" : "AM",
77
+ };
78
+ }
79
+ }
80
+
81
+ function createDateInTimezone(
82
+ year: number,
83
+ month: number,
84
+ day: number,
85
+ hour12: number,
86
+ minute: number,
87
+ ampm: "AM" | "PM",
88
+ timezone: string,
89
+ ): Date {
90
+ // Convert 12-hour to 24-hour
91
+ let hour24 = hour12;
92
+ if (ampm === "AM" && hour12 === 12) {
93
+ hour24 = 0;
94
+ } else if (ampm === "PM" && hour12 !== 12) {
95
+ hour24 = hour12 + 12;
96
+ }
97
+
98
+ const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour24).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
99
+
100
+ try {
101
+ // Get the timezone offset for this date
102
+ const testDate = new Date(dateStr + "Z");
103
+ const formatter = new Intl.DateTimeFormat("en-US", {
104
+ timeZone: timezone,
105
+ timeZoneName: "shortOffset",
106
+ });
107
+ const parts = formatter.formatToParts(testDate);
108
+ const offsetPart =
109
+ parts.find((p) => p.type === "timeZoneName")?.value || "";
110
+
111
+ const offsetMatch = offsetPart.match(/GMT([+-]?)(\d+)(?::(\d+))?/);
112
+ let offsetMinutes = 0;
113
+ if (offsetMatch) {
114
+ const sign = offsetMatch[1] === "-" ? -1 : 1;
115
+ const hours = parseInt(offsetMatch[2] || "0", 10);
116
+ const minutes = parseInt(offsetMatch[3] || "0", 10);
117
+ offsetMinutes = sign * (hours * 60 + minutes);
118
+ }
119
+
120
+ const utcDate = new Date(dateStr + "Z");
121
+ utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
122
+ return utcDate;
123
+ } catch {
124
+ // Fallback to local time
125
+ return new Date(year, month, day, hour24, minute);
126
+ }
127
+ }
128
+
129
+ function getDaysInMonth(year: number, month: number): number {
130
+ return new Date(year, month + 1, 0).getDate();
131
+ }
132
+
133
+ export function DateTimePicker({
134
+ value,
135
+ timezone,
136
+ focused,
137
+ onChange,
138
+ }: DateTimePickerProps) {
139
+ const parts = getDatePartsInTimezone(value, timezone);
140
+
141
+ const [activeField, setActiveField] = useState<Field>("month");
142
+ const [month, setMonth] = useState(parts.month);
143
+ const [day, setDay] = useState(parts.day);
144
+ const [year, setYear] = useState(parts.year);
145
+ const [hour, setHour] = useState(parts.hour12);
146
+ const [minute, setMinute] = useState(parts.minute);
147
+ const [ampm, setAmpm] = useState<"AM" | "PM">(parts.ampm);
148
+
149
+ // Update parent when values change
150
+ useEffect(() => {
151
+ const newDate = createDateInTimezone(
152
+ year,
153
+ month,
154
+ day,
155
+ hour,
156
+ minute,
157
+ ampm,
158
+ timezone,
159
+ );
160
+ if (newDate.getTime() !== value.getTime()) {
161
+ onChange(newDate);
162
+ }
163
+ }, [month, day, year, hour, minute, ampm]);
164
+
165
+ const fields: Field[] = ["month", "day", "year", "hour", "minute", "ampm"];
166
+
167
+ const handleKey = (key: string) => {
168
+ if (!focused) return;
169
+
170
+ // Navigation between fields
171
+ if (key === "tab" || key === "right" || key === "l") {
172
+ const idx = fields.indexOf(activeField);
173
+ setActiveField(fields[(idx + 1) % fields.length] as Field);
174
+ return;
175
+ }
176
+ if (key === "left" || key === "h") {
177
+ const idx = fields.indexOf(activeField);
178
+ setActiveField(
179
+ fields[(idx - 1 + fields.length) % fields.length] as Field,
180
+ );
181
+ return;
182
+ }
183
+
184
+ // Value changes
185
+ const increment =
186
+ key === "up" || key === "k" ? 1 : key === "down" || key === "j" ? -1 : 0;
187
+ if (increment === 0) return;
188
+
189
+ const daysInMonth = getDaysInMonth(year, month);
190
+
191
+ switch (activeField) {
192
+ case "month":
193
+ setMonth((m) => (m + increment + 12) % 12);
194
+ // Adjust day if needed
195
+ const newDaysInMonth = getDaysInMonth(
196
+ year,
197
+ (month + increment + 12) % 12,
198
+ );
199
+ if (day > newDaysInMonth) setDay(newDaysInMonth);
200
+ break;
201
+ case "day":
202
+ setDay((d) => {
203
+ const newDay = d + increment;
204
+ if (newDay < 1) return daysInMonth;
205
+ if (newDay > daysInMonth) return 1;
206
+ return newDay;
207
+ });
208
+ break;
209
+ case "year":
210
+ setYear((y) => Math.max(2020, Math.min(2030, y + increment)));
211
+ break;
212
+ case "hour":
213
+ setHour((h) => {
214
+ const newHour = h + increment;
215
+ if (newHour < 1) return 12;
216
+ if (newHour > 12) return 1;
217
+ return newHour;
218
+ });
219
+ break;
220
+ case "minute":
221
+ setMinute((m) => (m + increment + 60) % 60);
222
+ break;
223
+ case "ampm":
224
+ setAmpm((a) => (a === "AM" ? "PM" : "AM"));
225
+ break;
226
+ }
227
+ };
228
+
229
+ // Expose key handler via a custom hook pattern
230
+ useEffect(() => {
231
+ if (!focused) return;
232
+
233
+ const handler = (e: KeyboardEvent) => {
234
+ if (
235
+ [
236
+ "ArrowUp",
237
+ "ArrowDown",
238
+ "ArrowLeft",
239
+ "ArrowRight",
240
+ "Tab",
241
+ "h",
242
+ "j",
243
+ "k",
244
+ "l",
245
+ ].includes(e.key)
246
+ ) {
247
+ e.preventDefault();
248
+ const keyName = e.key.replace("Arrow", "").toLowerCase();
249
+ handleKey(keyName);
250
+ }
251
+ };
252
+
253
+ // Note: In @opentui/react, keyboard is handled differently
254
+ // This is a placeholder - the actual keyboard handling is done via useKeyboard in parent
255
+ }, [focused, activeField, month, day, year, hour, minute, ampm]);
256
+
257
+ const fieldStyle = (field: Field) => ({
258
+ paddingLeft: 1,
259
+ paddingRight: 1,
260
+ height: 3,
261
+ backgroundColor:
262
+ activeField === field && focused ? "#1e40af" : "transparent",
263
+ borderColor:
264
+ activeField === field && focused ? COLORS.border : COLORS.borderOff,
265
+ });
266
+
267
+ return {
268
+ activeField,
269
+ setActiveField,
270
+ fields,
271
+ handleKey,
272
+ render: (
273
+ <box style={{ flexDirection: "row", alignItems: "center" }}>
274
+ {/* Date section */}
275
+ <box
276
+ style={{
277
+ flexDirection: "row",
278
+ alignItems: "center",
279
+ }}
280
+ >
281
+ <box style={fieldStyle("month")}>
282
+ <text
283
+ fg={activeField === "month" && focused ? "#ffffff" : "#e2e8f0"}
284
+ >
285
+ {MONTHS[month]}
286
+ </text>
287
+ </box>
288
+ <box style={fieldStyle("day")}>
289
+ <text fg={activeField === "day" && focused ? "#ffffff" : "#e2e8f0"}>
290
+ {String(day).padStart(2, "0")}
291
+ </text>
292
+ </box>
293
+ <box style={fieldStyle("year")}>
294
+ <text
295
+ fg={activeField === "year" && focused ? "#ffffff" : "#e2e8f0"}
296
+ >
297
+ {year}
298
+ </text>
299
+ </box>
300
+ </box>
301
+
302
+ {/* Time section */}
303
+ <box
304
+ style={{
305
+ flexDirection: "row",
306
+ alignItems: "center",
307
+ }}
308
+ >
309
+ <box style={fieldStyle("hour")}>
310
+ <text
311
+ fg={activeField === "hour" && focused ? "#ffffff" : "#e2e8f0"}
312
+ >
313
+ {String(hour).padStart(2, "0")}
314
+ </text>
315
+ </box>
316
+ <text fg="#64748b">:</text>
317
+ <box style={fieldStyle("minute")}>
318
+ <text
319
+ fg={activeField === "minute" && focused ? "#ffffff" : "#e2e8f0"}
320
+ >
321
+ {String(minute).padStart(2, "0")}
322
+ </text>
323
+ </box>
324
+ <box style={fieldStyle("ampm")}>
325
+ <text
326
+ fg={activeField === "ampm" && focused ? "#ffffff" : "#e2e8f0"}
327
+ >
328
+ {ampm}
329
+ </text>
330
+ </box>
331
+ </box>
332
+ </box>
333
+ ),
334
+ };
335
+ }