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,293 @@
1
+ import { useState } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { COLORS, type TimeEntry } from "../types";
4
+ import Modal from "./Modal";
5
+ import { DateTimePicker } from "./DateTimePicker";
6
+ import { usePaste } from "../hooks/usePaste";
7
+
8
+ interface EditTimeEntryModalProps {
9
+ entry: TimeEntry & {
10
+ project: { name: string; color: string; hourlyRate: number | null };
11
+ };
12
+ timezone: string;
13
+ onSubmit: (startTime: Date, endTime: Date, description: string) => void;
14
+ onCancel: () => void;
15
+ }
16
+
17
+ function formatDuration(ms: number): string {
18
+ const totalSeconds = Math.floor(ms / 1000);
19
+ const hours = Math.floor(totalSeconds / 3600);
20
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
21
+
22
+ if (hours > 0) {
23
+ return `${hours}h ${minutes}m`;
24
+ }
25
+ return `${minutes}m`;
26
+ }
27
+
28
+ type ActiveSection = "start" | "end" | "description";
29
+
30
+ export function EditTimeEntryModal({
31
+ entry,
32
+ timezone,
33
+ onSubmit,
34
+ onCancel,
35
+ }: EditTimeEntryModalProps) {
36
+ const startDate =
37
+ entry.startTime instanceof Date
38
+ ? entry.startTime
39
+ : new Date(entry.startTime);
40
+ const endDate = entry.endTime
41
+ ? entry.endTime instanceof Date
42
+ ? entry.endTime
43
+ : new Date(entry.endTime)
44
+ : new Date();
45
+
46
+ const [startTime, setStartTime] = useState(startDate);
47
+ const [endTime, setEndTime] = useState(endDate);
48
+ const [description, setDescription] = useState(entry.description ?? "");
49
+ const [activeSection, setActiveSection] = useState<ActiveSection>("description");
50
+ const [error, setError] = useState<string | null>(null);
51
+ const descriptionInputRef = usePaste();
52
+
53
+ const startPicker = DateTimePicker({
54
+ value: startTime,
55
+ timezone,
56
+ focused: activeSection === "start",
57
+ onChange: (date) => {
58
+ setStartTime(date);
59
+ setError(null);
60
+ },
61
+ });
62
+
63
+ const endPicker = DateTimePicker({
64
+ value: endTime,
65
+ timezone,
66
+ focused: activeSection === "end",
67
+ onChange: (date) => {
68
+ setEndTime(date);
69
+ setError(null);
70
+ },
71
+ });
72
+
73
+ useKeyboard((key) => {
74
+ if (key.name === "escape") {
75
+ onCancel();
76
+ return;
77
+ }
78
+
79
+ // Submit on Enter from any section
80
+ if (key.name === "return") {
81
+ handleSubmit();
82
+ return;
83
+ }
84
+
85
+ // Switch between sections with Shift+Tab or specific keys
86
+ if (key.shift && key.name === "tab") {
87
+ setActiveSection((s) => {
88
+ if (s === "description") return "end";
89
+ if (s === "end") return "start";
90
+ return "description";
91
+ });
92
+ return;
93
+ }
94
+
95
+ // Handle date picker navigation when in start/end sections
96
+ if (activeSection === "start" || activeSection === "end") {
97
+ const picker = activeSection === "start" ? startPicker : endPicker;
98
+
99
+ // Navigate within picker
100
+ if (key.name === "tab" && !key.shift) {
101
+ // Check if we're at the last field of the picker
102
+ const currentIdx = picker.fields.indexOf(picker.activeField);
103
+ if (currentIdx === picker.fields.length - 1) {
104
+ // Move to next section
105
+ setActiveSection(activeSection === "start" ? "end" : "description");
106
+ } else {
107
+ picker.handleKey("tab");
108
+ }
109
+ return;
110
+ }
111
+
112
+ if (key.name === "left" || key.name === "h") {
113
+ const currentIdx = picker.fields.indexOf(picker.activeField);
114
+ if (currentIdx === 0 && activeSection === "end") {
115
+ setActiveSection("start");
116
+ startPicker.setActiveField("ampm");
117
+ } else {
118
+ picker.handleKey("left");
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (key.name === "right" || key.name === "l") {
124
+ const currentIdx = picker.fields.indexOf(picker.activeField);
125
+ if (currentIdx === picker.fields.length - 1) {
126
+ if (activeSection === "start") {
127
+ setActiveSection("end");
128
+ endPicker.setActiveField("month");
129
+ } else {
130
+ setActiveSection("description");
131
+ }
132
+ } else {
133
+ picker.handleKey("right");
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (
139
+ key.name === "up" ||
140
+ key.name === "k" ||
141
+ key.name === "down" ||
142
+ key.name === "j"
143
+ ) {
144
+ picker.handleKey(key.name);
145
+ return;
146
+ }
147
+ }
148
+
149
+ // Handle description field navigation
150
+ if (activeSection === "description") {
151
+ if (key.name === "tab" && !key.shift) {
152
+ setActiveSection("start");
153
+ return;
154
+ }
155
+ if (key.name === "left" || key.name === "h") {
156
+ // Let the input handle cursor movement
157
+ return;
158
+ }
159
+ }
160
+ });
161
+
162
+ const handleSubmit = () => {
163
+ if (endTime <= startTime) {
164
+ setError("End time must be after start time");
165
+ return;
166
+ }
167
+
168
+ onSubmit(startTime, endTime, description.trim());
169
+ };
170
+
171
+ // Calculate original duration
172
+ const originalEndDate = entry.endTime
173
+ ? entry.endTime instanceof Date
174
+ ? entry.endTime
175
+ : new Date(entry.endTime)
176
+ : new Date();
177
+ const originalStartDate =
178
+ entry.startTime instanceof Date
179
+ ? entry.startTime
180
+ : new Date(entry.startTime);
181
+ const originalDurationMs =
182
+ originalEndDate.getTime() - originalStartDate.getTime();
183
+
184
+ // Calculate new duration
185
+ const newDurationMs = endTime.getTime() - startTime.getTime();
186
+ const durationChangeMs = newDurationMs - originalDurationMs;
187
+
188
+ // Format duration change
189
+ let changeText = "";
190
+ let changeColor = "#64748b";
191
+ if (durationChangeMs !== 0) {
192
+ const sign = durationChangeMs > 0 ? "+" : "-";
193
+ changeText = ` (${sign}${formatDuration(Math.abs(durationChangeMs))})`;
194
+ changeColor = durationChangeMs > 0 ? "#10b981" : "#ef4444";
195
+ }
196
+
197
+ // Calculate amount if hourly rate exists
198
+ let amountPreview = "";
199
+ if (entry.project.hourlyRate && newDurationMs > 0) {
200
+ const amount = (newDurationMs / 3600000) * entry.project.hourlyRate;
201
+ amountPreview = `$${amount.toFixed(2)}`;
202
+ }
203
+
204
+ return (
205
+ <Modal title="Edit Time Entry" height={23}>
206
+ <box style={{ flexDirection: "row", marginTop: 1, gap: 2 }}>
207
+ <text>
208
+ <span fg={entry.project.color}>{entry.project.name}</span>
209
+ </text>
210
+ <text>
211
+ <span fg={newDurationMs > 0 ? "#ffffff" : "#ef4444"}>
212
+ {newDurationMs > 0 ? formatDuration(newDurationMs) : "0m"}
213
+ </span>
214
+ <span fg={changeColor}>{changeText}</span>
215
+ {amountPreview && <span fg="#10b981"> ({amountPreview})</span>}
216
+ </text>
217
+ </box>
218
+
219
+ {error && (
220
+ <box style={{ marginTop: 1 }}>
221
+ <text fg="#ef4444">{error}</text>
222
+ </box>
223
+ )}
224
+
225
+ <box
226
+ onClick={() => setActiveSection("start")}
227
+ style={{
228
+ flexDirection: "row",
229
+ marginTop: 1,
230
+ alignItems: "center",
231
+ gap: 2,
232
+ }}
233
+ >
234
+ <box style={{ width: 11 }}>
235
+ <text fg={activeSection === "start" ? "#ffffff" : "#94a3b8"}>
236
+ Start Time
237
+ </text>
238
+ </box>
239
+ <box>{startPicker.render}</box>
240
+ </box>
241
+
242
+ <box
243
+ onClick={() => setActiveSection("end")}
244
+ style={{
245
+ flexDirection: "row",
246
+ marginTop: 1,
247
+ alignItems: "center",
248
+ gap: 2,
249
+ }}
250
+ >
251
+ <box style={{ width: 11 }}>
252
+ <text fg={activeSection === "end" ? "#ffffff" : "#94a3b8"}>
253
+ End Time
254
+ </text>
255
+ </box>
256
+ <box>{endPicker.render}</box>
257
+ </box>
258
+
259
+ <box
260
+ onClick={() => setActiveSection("description")}
261
+ style={{ flexDirection: "column", marginTop: 1 }}
262
+ >
263
+ <box style={{ width: "100%" }}>
264
+ <text fg="#fff">Description</text>
265
+ </box>
266
+ <box
267
+ style={{
268
+ border: true,
269
+ borderColor:
270
+ activeSection === "description"
271
+ ? COLORS.border
272
+ : COLORS.borderOff,
273
+ width: "100%",
274
+ height: 3,
275
+ }}
276
+ >
277
+ <input
278
+ ref={descriptionInputRef}
279
+ placeholder="What did you work on?"
280
+ focused={activeSection === "description"}
281
+ onInput={setDescription}
282
+ onSubmit={handleSubmit}
283
+ value={description}
284
+ />
285
+ </box>
286
+ </box>
287
+
288
+ <box style={{ marginTop: 1 }}>
289
+ <text fg="#64748b">Tab: next field | Enter: save | Esc: cancel</text>
290
+ </box>
291
+ </Modal>
292
+ );
293
+ }
@@ -0,0 +1,65 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { View, RunningTimer } from "../types.ts";
3
+ import { Timer } from "./Timer.tsx";
4
+
5
+ interface HeaderProps {
6
+ currentView: View;
7
+ onViewChange: (view: View) => void;
8
+ runningTimer: RunningTimer | null;
9
+ onStopTimer: () => void;
10
+ }
11
+
12
+ const VIEWS: { key: View; label: string; shortcut: string }[] = [
13
+ { key: "dashboard", label: "dash", shortcut: "1" },
14
+ { key: "tasks", label: "tasks", shortcut: "2" },
15
+ { key: "timesheets", label: "sheets", shortcut: "3" },
16
+ { key: "invoices", label: "invoices", shortcut: "4" },
17
+ { key: "settings", label: "settings", shortcut: "5" },
18
+ { key: "help", label: "help", shortcut: "?" },
19
+ ];
20
+
21
+ export function Header({
22
+ currentView,
23
+ runningTimer,
24
+ onStopTimer,
25
+ }: HeaderProps) {
26
+ return (
27
+ <box
28
+ style={{
29
+ width: "100%",
30
+ flexDirection: "column",
31
+ }}
32
+ >
33
+ {/* Main header row */}
34
+ <box
35
+ style={{
36
+ width: "100%",
37
+ height: 1,
38
+ flexDirection: "row",
39
+ justifyContent: "space-between",
40
+ alignItems: "center",
41
+ paddingLeft: 1,
42
+ paddingRight: 1,
43
+ }}
44
+ >
45
+ <box
46
+ style={{
47
+ flexDirection: "row",
48
+ gap: 2,
49
+ alignItems: "center",
50
+ }}
51
+ >
52
+ {VIEWS.map((view) => (
53
+ <text key={view.key}>
54
+ <span fg="#3b82f6">{view.shortcut} </span>
55
+ <span fg={currentView === view.key ? "#ffffff" : "#64748b"}>
56
+ {view.label}
57
+ </span>
58
+ </text>
59
+ ))}
60
+ </box>
61
+ <Timer runningTimer={runningTimer} onStop={onStopTimer} />
62
+ </box>
63
+ </box>
64
+ );
65
+ }
@@ -0,0 +1,109 @@
1
+ const KEYBINDINGS = [
2
+ {
3
+ category: "Navigation",
4
+ bindings: [
5
+ { key: "1", action: "Go to Dashboard" },
6
+ { key: "2", action: "Go to Projects" },
7
+ { key: "3", action: "Go to Tasks" },
8
+ { key: "4", action: "Go to Settings" },
9
+ { key: "?", action: "Show Help" },
10
+ { key: "Tab", action: "Toggle panels" },
11
+ { key: "h/l or ←/→", action: "Switch panels" },
12
+ { key: "j/k or ↑/↓", action: "Move up/down in list" },
13
+ { key: "Enter", action: "Select / Confirm" },
14
+ { key: "Esc", action: "Cancel / Go back" },
15
+ { key: "q", action: "Quit Paca" },
16
+ ],
17
+ },
18
+ {
19
+ category: "Projects",
20
+ bindings: [
21
+ { key: "n", action: "Create new project" },
22
+ { key: "e", action: "Edit project" },
23
+ { key: "a", action: "Archive/Unarchive" },
24
+ { key: "d", action: "Delete project" },
25
+ { key: "A", action: "Toggle show archived" },
26
+ ],
27
+ },
28
+ {
29
+ category: "Tasks",
30
+ bindings: [
31
+ { key: "n", action: "Create new task" },
32
+ { key: "e", action: "Edit selected task" },
33
+ { key: "Space", action: "Toggle status (cycle)" },
34
+ { key: "p", action: "Change priority" },
35
+ { key: "d", action: "Delete task" },
36
+ ],
37
+ },
38
+ {
39
+ category: "Time Tracking",
40
+ bindings: [
41
+ { key: "t", action: "Start timer" },
42
+ { key: "s", action: "Stop running timer" },
43
+ ],
44
+ },
45
+ ];
46
+
47
+ export function HelpView() {
48
+ return (
49
+ <box
50
+ style={{
51
+ flexDirection: "column",
52
+ flexGrow: 1,
53
+ padding: 1,
54
+ }}
55
+ >
56
+ <box style={{ marginBottom: 1 }}>
57
+ <text fg="#64748b">A tui task management and timer</text>
58
+ </box>
59
+
60
+ <box
61
+ style={{
62
+ flexDirection: "row",
63
+ flexWrap: "wrap",
64
+ gap: 2,
65
+ flexGrow: 1,
66
+ }}
67
+ >
68
+ {KEYBINDINGS.map((section) => (
69
+ <box
70
+ key={section.category}
71
+ title={section.category}
72
+ style={{
73
+ border: true,
74
+ borderColor: "#334155",
75
+ padding: 1,
76
+ minWidth: 35,
77
+ flexGrow: 1,
78
+ }}
79
+ >
80
+ {section.bindings.map((binding) => (
81
+ <box
82
+ key={binding.key}
83
+ style={{
84
+ flexDirection: "row",
85
+ justifyContent: "space-between",
86
+ }}
87
+ >
88
+ <text>
89
+ <span fg="#3b82f6" style={{ minWidth: 12 }}>
90
+ {binding.key}
91
+ </span>
92
+ </text>
93
+ <text fg="#94a3b8">{binding.action}</text>
94
+ </box>
95
+ ))}
96
+ </box>
97
+ ))}
98
+ </box>
99
+
100
+ <box
101
+ style={{
102
+ padding: 1,
103
+ }}
104
+ >
105
+ <text fg="#475569">Database location: ~/.paca/paca.db</text>
106
+ </box>
107
+ </box>
108
+ );
109
+ }
@@ -0,0 +1,79 @@
1
+ import { useState } from "react";
2
+ import type { InputMode } from "../types.ts";
3
+ import Modal from "./Modal";
4
+ import { usePaste } from "../hooks/usePaste";
5
+
6
+ interface InputModalProps {
7
+ mode: InputMode;
8
+ title: string;
9
+ initialValue?: string;
10
+ placeholder?: string;
11
+ onSubmit: (value: string) => void;
12
+ onCancel: () => void;
13
+ }
14
+
15
+ export function InputModal({
16
+ title,
17
+ initialValue = "",
18
+ placeholder = "",
19
+ onSubmit,
20
+ }: InputModalProps) {
21
+ const [value, setValue] = useState(initialValue);
22
+ const inputRef = usePaste();
23
+
24
+ return (
25
+ <Modal title={title}>
26
+ <box
27
+ style={{
28
+ border: true,
29
+ borderColor: "#475569",
30
+ height: 3,
31
+ marginTop: 1,
32
+ }}
33
+ >
34
+ <input
35
+ ref={inputRef}
36
+ placeholder={placeholder}
37
+ focused
38
+ value={value}
39
+ onInput={setValue}
40
+ onSubmit={() => {
41
+ if (value.trim()) {
42
+ onSubmit(value.trim());
43
+ }
44
+ }}
45
+ />
46
+ </box>
47
+ </Modal>
48
+ );
49
+ }
50
+
51
+ interface ConfirmModalProps {
52
+ title: string;
53
+ message: string;
54
+ onConfirm: () => void;
55
+ onCancel: () => void;
56
+ }
57
+
58
+ export function ConfirmModal({ title, message }: ConfirmModalProps) {
59
+ return (
60
+ <Modal title={title}>
61
+ <text fg="#ef4444" attributes="bold">
62
+ {title}
63
+ </text>
64
+ <text fg="#e2e8f0" style={{ marginTop: 1 }}>
65
+ {message}
66
+ </text>
67
+ <box style={{ flexDirection: "row", gap: 2, marginTop: 1 }}>
68
+ <text>
69
+ <span fg="#10b981">[y]</span>
70
+ <span fg="#94a3b8"> Yes</span>
71
+ </text>
72
+ <text>
73
+ <span fg="#ef4444">[n]</span>
74
+ <span fg="#94a3b8"> No</span>
75
+ </text>
76
+ </box>
77
+ </Modal>
78
+ );
79
+ }