pacatui 0.1.0 → 0.1.5
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 +9 -8
- package/assets/paca-mascot.png +0 -0
- package/package.json +7 -3
- package/src/App.tsx +84 -42
- package/src/components/Dashboard.tsx +222 -10
- package/src/components/InvoicesView.tsx +24 -44
- package/src/components/StatusBar.tsx +2 -1
- package/src/components/TimerModals.tsx +64 -32
- package/src/components/TimesheetView.tsx +1 -17
- package/src/components/index.ts +0 -1
- package/src/db.ts +87 -0
- package/src/index.tsx +70 -0
- package/src/stripe.ts +32 -2
- package/src/types.ts +12 -0
- package/src/components/SplashScreen.tsx +0 -25
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Paca
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A simple TUI app for task, timer and invoicing for projects.
|
|
4
6
|
|
|
5
7
|

|
|
6
8
|

|
|
@@ -21,16 +23,15 @@ A simple tui app for task, timer and invoicing for projects.
|
|
|
21
23
|
|
|
22
24
|
### Via npm (recommended)
|
|
23
25
|
|
|
24
|
-
Requires [Bun](https://bun.sh) runtime.
|
|
25
|
-
|
|
26
26
|
```bash
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
npm install -g pacatui
|
|
28
|
+
paca
|
|
29
|
+
```
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
bun install -g pacatui
|
|
31
|
+
### Via bun
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
```bash
|
|
34
|
+
bun install -g pacatui
|
|
34
35
|
paca
|
|
35
36
|
```
|
|
36
37
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pacatui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "A simple tui app for task, timer and invoicing for projects.",
|
|
5
5
|
"module": "src/index.tsx",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"src",
|
|
13
13
|
"prisma",
|
|
14
|
-
"generated"
|
|
14
|
+
"generated",
|
|
15
|
+
"assets"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"start": "bun run src/index.tsx",
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
"db:push": "bunx prisma db push",
|
|
21
22
|
"db:studio": "bunx prisma studio",
|
|
22
23
|
"postinstall": "bunx prisma generate",
|
|
23
|
-
"prepublishOnly": "bunx prisma generate"
|
|
24
|
+
"prepublishOnly": "bunx prisma generate",
|
|
25
|
+
"release": "bunx bumpp && bun publish --access public"
|
|
24
26
|
},
|
|
25
27
|
"keywords": [
|
|
26
28
|
"tui",
|
|
@@ -64,6 +66,8 @@
|
|
|
64
66
|
"@prisma/adapter-libsql": "^7.2.0",
|
|
65
67
|
"@prisma/client": "^7.2.0",
|
|
66
68
|
"better-sqlite3": "^12.6.0",
|
|
69
|
+
"paca": "^1.0.10",
|
|
70
|
+
"pacatui": "^0.1.1",
|
|
67
71
|
"prisma": "^7.2.0",
|
|
68
72
|
"react": "^19.2.3",
|
|
69
73
|
"stripe": "^20.2.0"
|
package/src/App.tsx
CHANGED
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
ProjectModal,
|
|
15
15
|
ProjectSelectModal,
|
|
16
16
|
StopTimerModal,
|
|
17
|
-
SplashScreen,
|
|
18
17
|
TimesheetView,
|
|
19
18
|
CustomerModal,
|
|
20
19
|
CustomerSelectModal,
|
|
@@ -32,7 +31,7 @@ import {
|
|
|
32
31
|
customers,
|
|
33
32
|
invoices,
|
|
34
33
|
} from "./db.ts";
|
|
35
|
-
import { getOrCreateStripeCustomer, createDraftInvoice, listInvoices, type StripeInvoiceItem } from "./stripe.ts";
|
|
34
|
+
import { getOrCreateStripeCustomer, createDraftInvoice, listInvoices, clearInvoiceCache, type StripeInvoiceItem } from "./stripe.ts";
|
|
36
35
|
import {
|
|
37
36
|
getEffectiveTimezone,
|
|
38
37
|
type View,
|
|
@@ -47,6 +46,7 @@ import {
|
|
|
47
46
|
type Customer,
|
|
48
47
|
type TimeEntry,
|
|
49
48
|
type TimeEntryWithProject,
|
|
49
|
+
type WeeklyTimeData,
|
|
50
50
|
} from "./types.ts";
|
|
51
51
|
|
|
52
52
|
function formatDuration(ms: number): string {
|
|
@@ -64,9 +64,6 @@ function formatDuration(ms: number): string {
|
|
|
64
64
|
export function App() {
|
|
65
65
|
const renderer = useRenderer();
|
|
66
66
|
|
|
67
|
-
// Splash Screen State
|
|
68
|
-
const [showSplash, setShowSplash] = useState(true);
|
|
69
|
-
|
|
70
67
|
// View & Navigation State
|
|
71
68
|
const [currentView, setCurrentView] = useState<View>("dashboard");
|
|
72
69
|
const [activePanel, setActivePanel] = useState<Panel>("projects");
|
|
@@ -89,6 +86,7 @@ export function App() {
|
|
|
89
86
|
const [recentTasks, setRecentTasks] = useState<
|
|
90
87
|
(Task & { project: { name: string; color: string } })[]
|
|
91
88
|
>([]);
|
|
89
|
+
const [weeklyTimeData, setWeeklyTimeData] = useState<WeeklyTimeData[]>([]);
|
|
92
90
|
|
|
93
91
|
// Timer State
|
|
94
92
|
const [runningTimer, setRunningTimer] = useState<RunningTimer | null>(null);
|
|
@@ -171,14 +169,16 @@ export function App() {
|
|
|
171
169
|
}, [projectList, selectedProjectIndex, selectedTaskIndex]);
|
|
172
170
|
|
|
173
171
|
const loadDashboard = useCallback(async () => {
|
|
174
|
-
const [statsData, recent] = await Promise.all([
|
|
172
|
+
const [statsData, recent, weeklyTime] = await Promise.all([
|
|
175
173
|
stats.getDashboardStats(),
|
|
176
174
|
stats.getRecentActivity(10),
|
|
175
|
+
stats.getWeeklyTimeStats(6),
|
|
177
176
|
]);
|
|
178
177
|
setDashboardStats(statsData);
|
|
179
178
|
setRecentTasks(
|
|
180
179
|
recent as (Task & { project: { name: string; color: string } })[],
|
|
181
180
|
);
|
|
181
|
+
setWeeklyTimeData(weeklyTime);
|
|
182
182
|
}, []);
|
|
183
183
|
|
|
184
184
|
const loadRunningTimer = useCallback(async () => {
|
|
@@ -204,21 +204,27 @@ export function App() {
|
|
|
204
204
|
setCustomerList(data);
|
|
205
205
|
}, []);
|
|
206
206
|
|
|
207
|
-
const loadStripeInvoices = useCallback(async (cursor?: string, isNextPage = false) => {
|
|
207
|
+
const loadStripeInvoices = useCallback(async (cursor?: string, isNextPage = false, forceRefresh = false) => {
|
|
208
208
|
if (!appSettings.stripeApiKey) {
|
|
209
209
|
setStripeInvoices([]);
|
|
210
210
|
setInvoicesError(null);
|
|
211
211
|
return;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
|
|
214
|
+
// Only show loading spinner if we don't have existing data
|
|
215
|
+
const showLoading = stripeInvoices.length === 0;
|
|
216
|
+
if (showLoading) {
|
|
217
|
+
setInvoicesLoading(true);
|
|
218
|
+
}
|
|
215
219
|
setInvoicesError(null);
|
|
216
220
|
|
|
217
221
|
try {
|
|
218
|
-
const result = await listInvoices(appSettings.stripeApiKey, 25, cursor);
|
|
222
|
+
const result = await listInvoices(appSettings.stripeApiKey, 25, cursor, forceRefresh);
|
|
219
223
|
setStripeInvoices(result.invoices);
|
|
220
224
|
setInvoicesHasMore(result.hasMore);
|
|
221
|
-
|
|
225
|
+
if (showLoading || isNextPage) {
|
|
226
|
+
setSelectedInvoiceIndex(0);
|
|
227
|
+
}
|
|
222
228
|
|
|
223
229
|
// Track cursor for pagination
|
|
224
230
|
if (isNextPage && cursor) {
|
|
@@ -230,7 +236,7 @@ export function App() {
|
|
|
230
236
|
} finally {
|
|
231
237
|
setInvoicesLoading(false);
|
|
232
238
|
}
|
|
233
|
-
}, [appSettings.stripeApiKey]);
|
|
239
|
+
}, [appSettings.stripeApiKey, stripeInvoices.length]);
|
|
234
240
|
|
|
235
241
|
const loadTimesheets = useCallback(async () => {
|
|
236
242
|
const entries = await timeEntries.getUninvoiced();
|
|
@@ -287,12 +293,6 @@ export function App() {
|
|
|
287
293
|
}
|
|
288
294
|
}, [selectedTimesheetGroupIndex, selectedTimeEntryIndex]);
|
|
289
295
|
|
|
290
|
-
// Hide splash screen after 2 seconds
|
|
291
|
-
useEffect(() => {
|
|
292
|
-
const timer = setTimeout(() => setShowSplash(false), 1000);
|
|
293
|
-
return () => clearTimeout(timer);
|
|
294
|
-
}, []);
|
|
295
|
-
|
|
296
296
|
// Initial load
|
|
297
297
|
useEffect(() => {
|
|
298
298
|
loadProjects();
|
|
@@ -512,27 +512,43 @@ export function App() {
|
|
|
512
512
|
return;
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
-
// Toggle status
|
|
515
|
+
// Toggle status (update locally to preserve order)
|
|
516
516
|
if (key.name === "space" && recentTasks[selectedDashboardTaskIndex]) {
|
|
517
517
|
const task = recentTasks[selectedDashboardTaskIndex];
|
|
518
518
|
if (task) {
|
|
519
|
+
const statusCycle: Record<string, string> = {
|
|
520
|
+
todo: "in_progress",
|
|
521
|
+
in_progress: "done",
|
|
522
|
+
done: "todo",
|
|
523
|
+
};
|
|
524
|
+
const newStatus = statusCycle[task.status] || "todo";
|
|
519
525
|
tasks.toggleStatus(task.id).then(() => {
|
|
520
|
-
|
|
526
|
+
// Update local state without reordering
|
|
527
|
+
setRecentTasks((prev) =>
|
|
528
|
+
prev.map((t) =>
|
|
529
|
+
t.id === task.id ? { ...t, status: newStatus } : t,
|
|
530
|
+
),
|
|
531
|
+
);
|
|
521
532
|
});
|
|
522
533
|
}
|
|
523
534
|
return;
|
|
524
535
|
}
|
|
525
536
|
|
|
526
|
-
// Cycle priority
|
|
537
|
+
// Cycle priority (update locally to preserve order)
|
|
527
538
|
if (key.name === "p" && recentTasks[selectedDashboardTaskIndex]) {
|
|
528
539
|
const task = recentTasks[selectedDashboardTaskIndex];
|
|
529
540
|
if (task) {
|
|
530
|
-
const priorities = ["low", "medium", "high", "urgent"];
|
|
531
|
-
const currentIndex = priorities.indexOf(task.priority);
|
|
532
|
-
const nextPriority = priorities[(currentIndex + 1) % priorities.length];
|
|
541
|
+
const priorities = ["low", "medium", "high", "urgent"] as const;
|
|
542
|
+
const currentIndex = priorities.indexOf(task.priority as typeof priorities[number]);
|
|
543
|
+
const nextPriority = priorities[(currentIndex + 1) % priorities.length] ?? "low";
|
|
533
544
|
tasks.update(task.id, { priority: nextPriority }).then(() => {
|
|
534
545
|
showMessage(`Priority: ${nextPriority}`);
|
|
535
|
-
|
|
546
|
+
// Update local state without reordering
|
|
547
|
+
setRecentTasks((prev) =>
|
|
548
|
+
prev.map((t) =>
|
|
549
|
+
t.id === task.id ? { ...t, priority: nextPriority } : t,
|
|
550
|
+
),
|
|
551
|
+
);
|
|
536
552
|
});
|
|
537
553
|
}
|
|
538
554
|
return;
|
|
@@ -686,12 +702,23 @@ export function App() {
|
|
|
686
702
|
return;
|
|
687
703
|
}
|
|
688
704
|
|
|
689
|
-
// Toggle status
|
|
705
|
+
// Toggle status (update locally to preserve order)
|
|
690
706
|
if (key.name === "space" && taskList[selectedTaskIndex]) {
|
|
691
707
|
const task = taskList[selectedTaskIndex];
|
|
692
708
|
if (task) {
|
|
709
|
+
const statusCycle: Record<string, string> = {
|
|
710
|
+
todo: "in_progress",
|
|
711
|
+
in_progress: "done",
|
|
712
|
+
done: "todo",
|
|
713
|
+
};
|
|
714
|
+
const newStatus = statusCycle[task.status] || "todo";
|
|
693
715
|
tasks.toggleStatus(task.id).then(() => {
|
|
694
|
-
|
|
716
|
+
// Update local state without reordering
|
|
717
|
+
setTaskList((prev) =>
|
|
718
|
+
prev.map((t) =>
|
|
719
|
+
t.id === task.id ? { ...t, status: newStatus } : t,
|
|
720
|
+
),
|
|
721
|
+
);
|
|
695
722
|
loadDashboard();
|
|
696
723
|
});
|
|
697
724
|
}
|
|
@@ -714,16 +741,21 @@ export function App() {
|
|
|
714
741
|
return;
|
|
715
742
|
}
|
|
716
743
|
|
|
717
|
-
// Cycle priority
|
|
744
|
+
// Cycle priority (update locally to preserve order)
|
|
718
745
|
if (key.name === "p" && taskList[selectedTaskIndex]) {
|
|
719
746
|
const task = taskList[selectedTaskIndex];
|
|
720
747
|
if (task) {
|
|
721
|
-
const priorities = ["low", "medium", "high", "urgent"];
|
|
722
|
-
const currentIndex = priorities.indexOf(task.priority);
|
|
723
|
-
const nextPriority = priorities[(currentIndex + 1) % priorities.length];
|
|
748
|
+
const priorities = ["low", "medium", "high", "urgent"] as const;
|
|
749
|
+
const currentIndex = priorities.indexOf(task.priority as typeof priorities[number]);
|
|
750
|
+
const nextPriority = priorities[(currentIndex + 1) % priorities.length] ?? "low";
|
|
724
751
|
tasks.update(task.id, { priority: nextPriority }).then(() => {
|
|
725
752
|
showMessage(`Priority: ${nextPriority}`);
|
|
726
|
-
|
|
753
|
+
// Update local state without reordering
|
|
754
|
+
setTaskList((prev) =>
|
|
755
|
+
prev.map((t) =>
|
|
756
|
+
t.id === task.id ? { ...t, priority: nextPriority } : t,
|
|
757
|
+
),
|
|
758
|
+
);
|
|
727
759
|
});
|
|
728
760
|
}
|
|
729
761
|
return;
|
|
@@ -875,17 +907,28 @@ export function App() {
|
|
|
875
907
|
return;
|
|
876
908
|
}
|
|
877
909
|
|
|
878
|
-
// Open invoice in
|
|
910
|
+
// Open invoice in Stripe dashboard
|
|
879
911
|
if (key.name === "return") {
|
|
880
912
|
const invoice = stripeInvoices[selectedInvoiceIndex];
|
|
881
913
|
if (invoice) {
|
|
882
|
-
// Use hosted URL for finalized invoices, dashboard URL for drafts
|
|
883
|
-
const url = invoice.hostedUrl || invoice.dashboardUrl;
|
|
884
914
|
const { spawn } = require("child_process");
|
|
885
915
|
const platform = process.platform;
|
|
886
916
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
887
|
-
spawn(cmd, [
|
|
888
|
-
showMessage(
|
|
917
|
+
spawn(cmd, [invoice.dashboardUrl], { detached: true, stdio: "ignore" });
|
|
918
|
+
showMessage("Opening in Stripe dashboard...");
|
|
919
|
+
}
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Open public invoice URL
|
|
924
|
+
if (key.name === "p") {
|
|
925
|
+
const invoice = stripeInvoices[selectedInvoiceIndex];
|
|
926
|
+
if (invoice?.hostedUrl) {
|
|
927
|
+
const { spawn } = require("child_process");
|
|
928
|
+
const platform = process.platform;
|
|
929
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
930
|
+
spawn(cmd, [invoice.hostedUrl], { detached: true, stdio: "ignore" });
|
|
931
|
+
showMessage("Opening public invoice...");
|
|
889
932
|
}
|
|
890
933
|
return;
|
|
891
934
|
}
|
|
@@ -894,7 +937,7 @@ export function App() {
|
|
|
894
937
|
if (key.name === "r") {
|
|
895
938
|
setInvoicesPage(1);
|
|
896
939
|
setInvoicesCursors([]);
|
|
897
|
-
loadStripeInvoices();
|
|
940
|
+
loadStripeInvoices(undefined, false, true);
|
|
898
941
|
showMessage("Refreshing invoices...");
|
|
899
942
|
return;
|
|
900
943
|
}
|
|
@@ -1174,6 +1217,9 @@ export function App() {
|
|
|
1174
1217
|
// Update with Stripe invoice ID
|
|
1175
1218
|
await invoices.updateStripeId(invoice.id, stripeInvoiceId);
|
|
1176
1219
|
|
|
1220
|
+
// Clear invoice cache so new invoice appears on refresh
|
|
1221
|
+
clearInvoiceCache();
|
|
1222
|
+
|
|
1177
1223
|
showMessage(`Stripe draft invoice created: ${stripeInvoiceId}`);
|
|
1178
1224
|
|
|
1179
1225
|
// Clear selections and reload
|
|
@@ -1187,11 +1233,6 @@ export function App() {
|
|
|
1187
1233
|
|
|
1188
1234
|
const selectedProject = projectList[selectedProjectIndex];
|
|
1189
1235
|
|
|
1190
|
-
// Show splash screen on boot
|
|
1191
|
-
if (showSplash) {
|
|
1192
|
-
return <SplashScreen />;
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
1236
|
return (
|
|
1196
1237
|
<box
|
|
1197
1238
|
style={{
|
|
@@ -1212,6 +1253,7 @@ export function App() {
|
|
|
1212
1253
|
<Dashboard
|
|
1213
1254
|
stats={dashboardStats}
|
|
1214
1255
|
recentTasks={recentTasks}
|
|
1256
|
+
weeklyTimeData={weeklyTimeData}
|
|
1215
1257
|
selectedIndex={selectedDashboardTaskIndex}
|
|
1216
1258
|
focused={true}
|
|
1217
1259
|
/>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import { useTerminalDimensions } from "@opentui/react";
|
|
2
|
-
import type { DashboardStats, Task } from "../types.ts";
|
|
3
|
+
import type { DashboardStats, Task, WeeklyTimeData } from "../types.ts";
|
|
3
4
|
import { COLORS } from "../types.ts";
|
|
4
5
|
|
|
5
6
|
interface DashboardProps {
|
|
6
7
|
stats: DashboardStats;
|
|
7
8
|
recentTasks: (Task & { project: { name: string; color: string } })[];
|
|
9
|
+
weeklyTimeData: WeeklyTimeData[];
|
|
8
10
|
selectedIndex: number;
|
|
9
11
|
focused: boolean;
|
|
10
12
|
}
|
|
@@ -88,9 +90,217 @@ function StackedBarChart({
|
|
|
88
90
|
);
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
function formatHours(ms: number): string {
|
|
94
|
+
const hours = ms / 3600000;
|
|
95
|
+
if (hours < 1) {
|
|
96
|
+
const minutes = Math.round(ms / 60000);
|
|
97
|
+
return `${minutes}m`;
|
|
98
|
+
}
|
|
99
|
+
return `${hours.toFixed(1)}h`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Distinct color palette for chart bars
|
|
103
|
+
const CHART_COLORS = [
|
|
104
|
+
"#3b82f6", // Blue
|
|
105
|
+
"#10b981", // Green
|
|
106
|
+
"#f59e0b", // Amber
|
|
107
|
+
"#ef4444", // Red
|
|
108
|
+
"#8b5cf6", // Purple
|
|
109
|
+
"#ec4899", // Pink
|
|
110
|
+
"#06b6d4", // Cyan
|
|
111
|
+
"#f97316", // Orange
|
|
112
|
+
"#84cc16", // Lime
|
|
113
|
+
"#6366f1", // Indigo
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function WeeklyTimeChart({
|
|
117
|
+
data,
|
|
118
|
+
width,
|
|
119
|
+
}: {
|
|
120
|
+
data: WeeklyTimeData[];
|
|
121
|
+
width: number;
|
|
122
|
+
}) {
|
|
123
|
+
const chartWidth = Math.max(width + 4, 40);
|
|
124
|
+
const maxBars = Math.min(data.length, 25, Math.floor(chartWidth / 3)); // Each bar needs ~3 chars min, max 25 weeks
|
|
125
|
+
const recentData = data.slice(-maxBars);
|
|
126
|
+
|
|
127
|
+
if (recentData.length === 0) {
|
|
128
|
+
return (
|
|
129
|
+
<box style={{ flexDirection: "column", gap: 1, width: "100%" }}>
|
|
130
|
+
<text fg="#64748b">No time entries in the last 6 months</text>
|
|
131
|
+
</box>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Find max hours for scaling
|
|
136
|
+
const maxMs = Math.max(...recentData.map((d) => d.totalMs), 1);
|
|
137
|
+
const barHeight = 5; // Height of bars in rows
|
|
138
|
+
|
|
139
|
+
// Calculate bar width to fill available space
|
|
140
|
+
// Total width = (barWidth * n) + (n - 1) spaces = chartWidth
|
|
141
|
+
// barWidth = (chartWidth - n + 1) / n
|
|
142
|
+
|
|
143
|
+
const barWidth = 4;
|
|
144
|
+
|
|
145
|
+
// Collect unique projects for legend (sorted by total time across all weeks)
|
|
146
|
+
const projectTotals = new Map<
|
|
147
|
+
string,
|
|
148
|
+
{ name: string; color: string; totalMs: number }
|
|
149
|
+
>();
|
|
150
|
+
for (const week of recentData) {
|
|
151
|
+
for (const p of week.projects) {
|
|
152
|
+
const existing = projectTotals.get(p.projectId);
|
|
153
|
+
if (existing) {
|
|
154
|
+
existing.totalMs += p.ms;
|
|
155
|
+
} else {
|
|
156
|
+
projectTotals.set(p.projectId, {
|
|
157
|
+
name: p.projectName,
|
|
158
|
+
color: p.projectColor,
|
|
159
|
+
totalMs: p.ms,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Sort projects by total time (descending) for consistent stacking order
|
|
165
|
+
const sortedProjects = Array.from(projectTotals.entries()).sort(
|
|
166
|
+
(a, b) => b[1].totalMs - a[1].totalMs,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Assign dynamic colors to each project based on their sorted position
|
|
170
|
+
const projectColorMap = new Map<string, string>();
|
|
171
|
+
sortedProjects.forEach(([projectId], index) => {
|
|
172
|
+
projectColorMap.set(projectId, CHART_COLORS[index % CHART_COLORS.length]!);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Pre-sort each week's projects by the global order for consistent stacking
|
|
176
|
+
const weekProjectsSorted = recentData.map((week) =>
|
|
177
|
+
[...week.projects].sort((a, b) => {
|
|
178
|
+
const aIdx = sortedProjects.findIndex(([id]) => id === a.projectId);
|
|
179
|
+
const bIdx = sortedProjects.findIndex(([id]) => id === b.projectId);
|
|
180
|
+
return aIdx - bIdx;
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Pre-calculate bar heights for label positioning
|
|
185
|
+
const barHeights = recentData.map((week) =>
|
|
186
|
+
Math.round((week.totalMs / maxMs) * barHeight),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Build bars row by row (from top to bottom, +1 row for labels on tallest bars)
|
|
190
|
+
const rows: ReactNode[] = [];
|
|
191
|
+
for (let row = barHeight; row >= 0; row--) {
|
|
192
|
+
const rowParts: ReactNode[] = [];
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < recentData.length; i++) {
|
|
195
|
+
const week = recentData[i]!;
|
|
196
|
+
const barTotalRows = barHeights[i]!;
|
|
197
|
+
|
|
198
|
+
if (row < barTotalRows) {
|
|
199
|
+
// This row should be filled - determine which project's color
|
|
200
|
+
// Use fraction-based calculation to avoid rounding errors
|
|
201
|
+
const rowFraction = (row + 0.5) / barTotalRows;
|
|
202
|
+
const weekProjects = weekProjectsSorted[i]!;
|
|
203
|
+
|
|
204
|
+
// Find which project this row belongs to based on cumulative fraction
|
|
205
|
+
let cumulativeFraction = 0;
|
|
206
|
+
let projectColor = CHART_COLORS[0]!;
|
|
207
|
+
|
|
208
|
+
for (const proj of weekProjects) {
|
|
209
|
+
const projFraction = proj.ms / week.totalMs;
|
|
210
|
+
cumulativeFraction += projFraction;
|
|
211
|
+
if (rowFraction <= cumulativeFraction) {
|
|
212
|
+
projectColor =
|
|
213
|
+
projectColorMap.get(proj.projectId) || CHART_COLORS[0]!;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
rowParts.push(
|
|
219
|
+
<span key={i} fg={projectColor}>
|
|
220
|
+
{"█".repeat(barWidth)}
|
|
221
|
+
</span>,
|
|
222
|
+
);
|
|
223
|
+
} else if (row === barTotalRows && week.totalMs > 0) {
|
|
224
|
+
// Show hour label just above the bar (centered)
|
|
225
|
+
const label = formatHours(week.totalMs);
|
|
226
|
+
const trimmed = label.slice(0, barWidth);
|
|
227
|
+
const padLeft = Math.floor((barWidth - trimmed.length) / 2);
|
|
228
|
+
const centered =
|
|
229
|
+
" ".repeat(padLeft) +
|
|
230
|
+
trimmed +
|
|
231
|
+
" ".repeat(barWidth - padLeft - trimmed.length);
|
|
232
|
+
rowParts.push(
|
|
233
|
+
<span key={i} fg="#64748b">
|
|
234
|
+
{centered}
|
|
235
|
+
</span>,
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
rowParts.push(<span key={i}>{" ".repeat(barWidth)}</span>);
|
|
239
|
+
}
|
|
240
|
+
// Add space between bars
|
|
241
|
+
if (i < recentData.length - 1) {
|
|
242
|
+
rowParts.push(<span key={`sp-${i}`}> </span>);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
rows.push(<text key={row}>{rowParts}</text>);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Calculate total hours for display
|
|
250
|
+
const totalMs = recentData.reduce((sum, w) => sum + w.totalMs, 0);
|
|
251
|
+
|
|
252
|
+
// Build date labels row
|
|
253
|
+
const dateLabelParts: ReactNode[] = [];
|
|
254
|
+
for (let i = 0; i < recentData.length; i++) {
|
|
255
|
+
const week = recentData[i]!;
|
|
256
|
+
const trimmed = week.weekLabel.slice(0, barWidth);
|
|
257
|
+
const padLeft = Math.floor((barWidth - trimmed.length) / 2);
|
|
258
|
+
const centered =
|
|
259
|
+
" ".repeat(padLeft) +
|
|
260
|
+
trimmed +
|
|
261
|
+
" ".repeat(barWidth - padLeft - trimmed.length);
|
|
262
|
+
dateLabelParts.push(
|
|
263
|
+
<span key={i} fg="#64748b">
|
|
264
|
+
{centered}
|
|
265
|
+
</span>,
|
|
266
|
+
);
|
|
267
|
+
if (i < recentData.length - 1) {
|
|
268
|
+
dateLabelParts.push(<span key={`sp-${i}`}> </span>);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<box style={{ flexDirection: "column", gap: 1, width: "100%" }}>
|
|
274
|
+
{/* Bars (with hour labels at top) */}
|
|
275
|
+
{rows}
|
|
276
|
+
{/* Date labels */}
|
|
277
|
+
<text>{dateLabelParts}</text>
|
|
278
|
+
|
|
279
|
+
{/* Legend - show top projects by time */}
|
|
280
|
+
<box
|
|
281
|
+
style={{ flexDirection: "row", gap: 2, marginTop: 1, flexWrap: "wrap" }}
|
|
282
|
+
>
|
|
283
|
+
{sortedProjects.slice(0, 4).map(([id, proj]) => (
|
|
284
|
+
<text key={id}>
|
|
285
|
+
<span fg={projectColorMap.get(id)}>●</span>
|
|
286
|
+
<span fg="#94a3b8"> {proj.name.slice(0, 12)}</span>
|
|
287
|
+
</text>
|
|
288
|
+
))}
|
|
289
|
+
<text>
|
|
290
|
+
<span fg="#94a3b8">Total: </span>
|
|
291
|
+
<span fg="#ffffff" attributes="bold">
|
|
292
|
+
{formatHours(totalMs)}
|
|
293
|
+
</span>
|
|
294
|
+
</text>
|
|
295
|
+
</box>
|
|
296
|
+
</box>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
91
300
|
export function Dashboard({
|
|
92
301
|
stats,
|
|
93
302
|
recentTasks,
|
|
303
|
+
weeklyTimeData,
|
|
94
304
|
selectedIndex,
|
|
95
305
|
focused,
|
|
96
306
|
}: DashboardProps) {
|
|
@@ -124,22 +334,24 @@ export function Dashboard({
|
|
|
124
334
|
style={{
|
|
125
335
|
flexDirection: "column",
|
|
126
336
|
flexGrow: 1,
|
|
127
|
-
padding:
|
|
128
|
-
gap:
|
|
337
|
+
padding: 2,
|
|
338
|
+
gap: 3,
|
|
129
339
|
}}
|
|
130
340
|
>
|
|
131
341
|
{/* Progress Section */}
|
|
342
|
+
<box title="Task Status">
|
|
343
|
+
<StackedBarChart stats={stats} width={termWidth} />
|
|
344
|
+
</box>
|
|
345
|
+
|
|
346
|
+
{/* Weekly Time Chart */}
|
|
132
347
|
<box
|
|
133
|
-
title="
|
|
348
|
+
title="Weekly Time"
|
|
134
349
|
style={{
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
padding: 1,
|
|
138
|
-
flexDirection: "column",
|
|
139
|
-
width: "100%",
|
|
350
|
+
justifyContent: "center",
|
|
351
|
+
alignItems: "center",
|
|
140
352
|
}}
|
|
141
353
|
>
|
|
142
|
-
<
|
|
354
|
+
<WeeklyTimeChart data={weeklyTimeData} width={termWidth} />
|
|
143
355
|
</box>
|
|
144
356
|
|
|
145
357
|
{/* Recent Activity */}
|
|
@@ -22,7 +22,7 @@ function formatCurrency(amount: number, currency: string): string {
|
|
|
22
22
|
|
|
23
23
|
function formatDate(date: Date): string {
|
|
24
24
|
return date.toLocaleDateString("en-US", {
|
|
25
|
-
month: "
|
|
25
|
+
month: "numeric",
|
|
26
26
|
day: "numeric",
|
|
27
27
|
year: "numeric",
|
|
28
28
|
});
|
|
@@ -45,13 +45,12 @@ function getStatusColor(status: string): string {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
// Column widths for table layout
|
|
48
|
+
// Column widths for table layout (client column uses flexGrow for 100% width)
|
|
49
49
|
const COL = {
|
|
50
|
-
|
|
51
|
-
date: 14,
|
|
52
|
-
customer: 24,
|
|
50
|
+
invoiceId: 18,
|
|
53
51
|
status: 10,
|
|
54
|
-
|
|
52
|
+
date: 14,
|
|
53
|
+
amount: 14,
|
|
55
54
|
};
|
|
56
55
|
|
|
57
56
|
export function InvoicesView({
|
|
@@ -82,7 +81,7 @@ export function InvoicesView({
|
|
|
82
81
|
>
|
|
83
82
|
<text fg="#ffffff">Invoices</text>
|
|
84
83
|
</box>
|
|
85
|
-
<text fg={COLORS.borderOff}>{"─".repeat(
|
|
84
|
+
<text fg={COLORS.borderOff}>{"─".repeat(200)}</text>
|
|
86
85
|
<box
|
|
87
86
|
style={{
|
|
88
87
|
flexGrow: 1,
|
|
@@ -114,7 +113,7 @@ export function InvoicesView({
|
|
|
114
113
|
>
|
|
115
114
|
<text fg="#ffffff">Invoices</text>
|
|
116
115
|
</box>
|
|
117
|
-
|
|
116
|
+
|
|
118
117
|
<box
|
|
119
118
|
style={{
|
|
120
119
|
flexGrow: 1,
|
|
@@ -145,7 +144,7 @@ export function InvoicesView({
|
|
|
145
144
|
>
|
|
146
145
|
<text fg="#ffffff">Invoices</text>
|
|
147
146
|
</box>
|
|
148
|
-
|
|
147
|
+
|
|
149
148
|
<box
|
|
150
149
|
style={{
|
|
151
150
|
flexGrow: 1,
|
|
@@ -176,7 +175,7 @@ export function InvoicesView({
|
|
|
176
175
|
>
|
|
177
176
|
<text fg="#ffffff">Invoices</text>
|
|
178
177
|
</box>
|
|
179
|
-
|
|
178
|
+
|
|
180
179
|
<box
|
|
181
180
|
style={{
|
|
182
181
|
flexGrow: 1,
|
|
@@ -198,21 +197,6 @@ export function InvoicesView({
|
|
|
198
197
|
padding: 1,
|
|
199
198
|
}}
|
|
200
199
|
>
|
|
201
|
-
{/* Header */}
|
|
202
|
-
<box
|
|
203
|
-
style={{
|
|
204
|
-
flexDirection: "row",
|
|
205
|
-
justifyContent: "space-between",
|
|
206
|
-
}}
|
|
207
|
-
>
|
|
208
|
-
<text fg="#ffffff">Invoices</text>
|
|
209
|
-
<text fg="#94a3b8">
|
|
210
|
-
Page {currentPage} {hasMore && "| ] next"} {hasPrevious && "| [ prev"}
|
|
211
|
-
</text>
|
|
212
|
-
</box>
|
|
213
|
-
<text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
|
|
214
|
-
|
|
215
|
-
{/* Column Headers */}
|
|
216
200
|
<box
|
|
217
201
|
style={{
|
|
218
202
|
flexDirection: "row",
|
|
@@ -220,23 +204,23 @@ export function InvoicesView({
|
|
|
220
204
|
paddingRight: 1,
|
|
221
205
|
}}
|
|
222
206
|
>
|
|
223
|
-
<box style={{ width: COL.
|
|
224
|
-
<text fg="#64748b">Invoice
|
|
207
|
+
<box style={{ width: COL.invoiceId }}>
|
|
208
|
+
<text fg="#64748b">Invoice ID</text>
|
|
225
209
|
</box>
|
|
226
210
|
<box style={{ width: COL.date }}>
|
|
227
211
|
<text fg="#64748b">Date</text>
|
|
228
212
|
</box>
|
|
229
|
-
<box style={{ width: COL.customer }}>
|
|
230
|
-
<text fg="#64748b">Customer</text>
|
|
231
|
-
</box>
|
|
232
213
|
<box style={{ width: COL.status }}>
|
|
233
214
|
<text fg="#64748b">Status</text>
|
|
234
215
|
</box>
|
|
216
|
+
<box style={{ flexGrow: 1 }}>
|
|
217
|
+
<text fg="#64748b">Client</text>
|
|
218
|
+
</box>
|
|
219
|
+
|
|
235
220
|
<box style={{ width: COL.amount, alignItems: "flex-end" }}>
|
|
236
221
|
<text fg="#64748b">Amount</text>
|
|
237
222
|
</box>
|
|
238
223
|
</box>
|
|
239
|
-
<text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
|
|
240
224
|
|
|
241
225
|
{/* Invoice List */}
|
|
242
226
|
<scrollbox focused={focused} style={{ flexGrow: 1 }}>
|
|
@@ -255,8 +239,8 @@ export function InvoicesView({
|
|
|
255
239
|
paddingRight: 1,
|
|
256
240
|
}}
|
|
257
241
|
>
|
|
258
|
-
{/* Invoice
|
|
259
|
-
<box style={{ width: COL.
|
|
242
|
+
{/* Invoice ID */}
|
|
243
|
+
<box style={{ width: COL.invoiceId }}>
|
|
260
244
|
<text fg={isSelected ? "#ffffff" : "#e2e8f0"}>
|
|
261
245
|
{invoice.number || invoice.id.slice(-8)}
|
|
262
246
|
</text>
|
|
@@ -265,19 +249,19 @@ export function InvoicesView({
|
|
|
265
249
|
<box style={{ width: COL.date }}>
|
|
266
250
|
<text fg="#94a3b8">{formatDate(invoice.created)}</text>
|
|
267
251
|
</box>
|
|
268
|
-
{/* Customer */}
|
|
269
|
-
<box style={{ width: COL.customer }}>
|
|
270
|
-
<text fg={isSelected ? "#ffffff" : "#e2e8f0"}>
|
|
271
|
-
{(invoice.customerName || invoice.customerEmail || "Unknown")
|
|
272
|
-
.slice(0, COL.customer - 2)}
|
|
273
|
-
</text>
|
|
274
|
-
</box>
|
|
275
252
|
{/* Status */}
|
|
276
253
|
<box style={{ width: COL.status }}>
|
|
277
254
|
<text fg={getStatusColor(invoice.status)}>
|
|
278
255
|
{invoice.status}
|
|
279
256
|
</text>
|
|
280
257
|
</box>
|
|
258
|
+
{/* Client */}
|
|
259
|
+
<box style={{ flexGrow: 1 }}>
|
|
260
|
+
<text fg={isSelected ? "#ffffff" : "#e2e8f0"}>
|
|
261
|
+
{invoice.customerName || invoice.customerEmail || "Unknown"}
|
|
262
|
+
</text>
|
|
263
|
+
</box>
|
|
264
|
+
|
|
281
265
|
{/* Amount */}
|
|
282
266
|
<box style={{ width: COL.amount, alignItems: "flex-end" }}>
|
|
283
267
|
<text fg="#10b981">
|
|
@@ -288,10 +272,6 @@ export function InvoicesView({
|
|
|
288
272
|
);
|
|
289
273
|
})}
|
|
290
274
|
</scrollbox>
|
|
291
|
-
|
|
292
|
-
{/* Footer */}
|
|
293
|
-
<text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
|
|
294
|
-
<text fg="#64748b">Enter to open in browser | r to refresh</text>
|
|
295
275
|
</box>
|
|
296
276
|
);
|
|
297
277
|
}
|
|
@@ -30,7 +30,8 @@ function getShortCuts(
|
|
|
30
30
|
return [
|
|
31
31
|
...baseShortcuts,
|
|
32
32
|
{ key: "⇅", action: "navigate" },
|
|
33
|
-
{ key: "↵", action: "open" },
|
|
33
|
+
{ key: "↵", action: "open in Stripe" },
|
|
34
|
+
{ key: "p", action: "public URL" },
|
|
34
35
|
{ key: "r", action: "refresh" },
|
|
35
36
|
{ key: "[]", action: "page" },
|
|
36
37
|
];
|
|
@@ -16,21 +16,32 @@ export function ProjectSelectModal({
|
|
|
16
16
|
onCancel,
|
|
17
17
|
}: ProjectSelectModalProps) {
|
|
18
18
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
19
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
20
|
+
const inputRef = usePaste();
|
|
21
|
+
|
|
22
|
+
const filteredProjects = projects.filter((project) =>
|
|
23
|
+
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const handleSearchChange = (value: string) => {
|
|
27
|
+
setSearchQuery(value);
|
|
28
|
+
setSelectedIndex(0);
|
|
29
|
+
};
|
|
19
30
|
|
|
20
31
|
useKeyboard((key) => {
|
|
21
32
|
if (key.name === "escape") {
|
|
22
33
|
onCancel();
|
|
23
34
|
return;
|
|
24
35
|
}
|
|
25
|
-
if (key.name === "return" &&
|
|
26
|
-
onSelect(
|
|
36
|
+
if (key.name === "return" && filteredProjects[selectedIndex]) {
|
|
37
|
+
onSelect(filteredProjects[selectedIndex].id);
|
|
27
38
|
return;
|
|
28
39
|
}
|
|
29
|
-
if (key.name === "
|
|
30
|
-
setSelectedIndex((i) => Math.min(i + 1,
|
|
40
|
+
if (key.name === "down") {
|
|
41
|
+
setSelectedIndex((i) => Math.min(i + 1, filteredProjects.length - 1));
|
|
31
42
|
return;
|
|
32
43
|
}
|
|
33
|
-
if (key.name === "
|
|
44
|
+
if (key.name === "up") {
|
|
34
45
|
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
35
46
|
return;
|
|
36
47
|
}
|
|
@@ -38,35 +49,56 @@ export function ProjectSelectModal({
|
|
|
38
49
|
|
|
39
50
|
return (
|
|
40
51
|
<Modal title="Start Timer" height={20}>
|
|
41
|
-
<box
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
<box
|
|
53
|
+
style={{
|
|
54
|
+
border: true,
|
|
55
|
+
borderColor: "#475569",
|
|
56
|
+
height: 3,
|
|
57
|
+
marginBottom: 1,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<input
|
|
61
|
+
ref={inputRef}
|
|
62
|
+
placeholder="Search projects..."
|
|
63
|
+
focused
|
|
64
|
+
value={searchQuery}
|
|
65
|
+
onInput={handleSearchChange}
|
|
66
|
+
/>
|
|
67
|
+
</box>
|
|
68
|
+
<box style={{ flexGrow: 1 }}>
|
|
69
|
+
<scrollbox style={{ flexGrow: 1 }}>
|
|
70
|
+
{filteredProjects.length === 0 ? (
|
|
71
|
+
<text fg="#64748b" style={{ paddingLeft: 1 }}>
|
|
72
|
+
No projects found
|
|
73
|
+
</text>
|
|
74
|
+
) : (
|
|
75
|
+
filteredProjects.map((project, index) => (
|
|
76
|
+
<box
|
|
77
|
+
key={project.id}
|
|
78
|
+
style={{
|
|
79
|
+
paddingLeft: 1,
|
|
80
|
+
paddingRight: 1,
|
|
81
|
+
backgroundColor:
|
|
82
|
+
index === selectedIndex ? "#1e40af" : "transparent",
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<text>
|
|
86
|
+
<span fg={project.color}>[*] </span>
|
|
87
|
+
<span
|
|
88
|
+
fg={index === selectedIndex ? "#ffffff" : "#e2e8f0"}
|
|
89
|
+
attributes={index === selectedIndex ? "bold" : undefined}
|
|
90
|
+
>
|
|
91
|
+
{project.name}
|
|
92
|
+
</span>
|
|
93
|
+
{project.hourlyRate != null && (
|
|
94
|
+
<span fg="#10b981"> ${project.hourlyRate}/hr</span>
|
|
95
|
+
)}
|
|
96
|
+
</text>
|
|
97
|
+
</box>
|
|
98
|
+
))
|
|
99
|
+
)}
|
|
67
100
|
</scrollbox>
|
|
68
101
|
</box>
|
|
69
|
-
<text fg="#64748b">Enter to select, Esc to cancel</text>
|
|
70
102
|
</Modal>
|
|
71
103
|
);
|
|
72
104
|
}
|
|
@@ -62,9 +62,8 @@ export function TimesheetView({
|
|
|
62
62
|
}}
|
|
63
63
|
>
|
|
64
64
|
<text fg="#ffffff">Timesheets</text>
|
|
65
|
-
<text fg="#94a3b8">Uninvoiced Time</text>
|
|
66
65
|
</box>
|
|
67
|
-
|
|
66
|
+
|
|
68
67
|
<box
|
|
69
68
|
style={{
|
|
70
69
|
flexGrow: 1,
|
|
@@ -86,21 +85,6 @@ export function TimesheetView({
|
|
|
86
85
|
padding: 1,
|
|
87
86
|
}}
|
|
88
87
|
>
|
|
89
|
-
<box
|
|
90
|
-
style={{
|
|
91
|
-
flexDirection: "row",
|
|
92
|
-
justifyContent: "space-between",
|
|
93
|
-
}}
|
|
94
|
-
>
|
|
95
|
-
<text fg="#ffffff">Timesheets</text>
|
|
96
|
-
<text fg="#94a3b8">
|
|
97
|
-
{selectedEntryIds.size > 0
|
|
98
|
-
? `${selectedEntryIds.size} selected`
|
|
99
|
-
: "Uninvoiced Time"}
|
|
100
|
-
</text>
|
|
101
|
-
</box>
|
|
102
|
-
<text fg={COLORS.borderOff}>{"─".repeat(56)}</text>
|
|
103
|
-
|
|
104
88
|
<scrollbox focused={focused} style={{ flexGrow: 1 }}>
|
|
105
89
|
{groups.map((group, groupIndex) => {
|
|
106
90
|
const isSelectedGroup = groupIndex === selectedGroupIndex;
|
package/src/components/index.ts
CHANGED
|
@@ -9,7 +9,6 @@ export { InputModal, ConfirmModal } from "./InputModal.tsx";
|
|
|
9
9
|
export { ProjectModal } from "./ProjectModal.tsx";
|
|
10
10
|
export { Timer, formatDurationHuman } from "./Timer.tsx";
|
|
11
11
|
export { ProjectSelectModal, StopTimerModal } from "./TimerModals.tsx";
|
|
12
|
-
export { SplashScreen } from "./SplashScreen.tsx";
|
|
13
12
|
export { TimesheetView } from "./TimesheetView.tsx";
|
|
14
13
|
export { CustomerModal } from "./CustomerModal.tsx";
|
|
15
14
|
export { CustomerSelectModal } from "./CustomerSelectModal.tsx";
|
package/src/db.ts
CHANGED
|
@@ -343,6 +343,93 @@ export const stats = {
|
|
|
343
343
|
monthMs: calcDuration(monthEntries),
|
|
344
344
|
};
|
|
345
345
|
},
|
|
346
|
+
|
|
347
|
+
async getWeeklyTimeStats(months = 6) {
|
|
348
|
+
// Get date 6 months ago
|
|
349
|
+
const startDate = new Date();
|
|
350
|
+
startDate.setMonth(startDate.getMonth() - months);
|
|
351
|
+
startDate.setHours(0, 0, 0, 0);
|
|
352
|
+
// Set to start of that week (Sunday)
|
|
353
|
+
startDate.setDate(startDate.getDate() - startDate.getDay());
|
|
354
|
+
|
|
355
|
+
// Get all time entries in this range
|
|
356
|
+
const entries = await db.timeEntry.findMany({
|
|
357
|
+
where: {
|
|
358
|
+
startTime: { gte: startDate },
|
|
359
|
+
endTime: { not: null },
|
|
360
|
+
},
|
|
361
|
+
include: {
|
|
362
|
+
project: {
|
|
363
|
+
select: { id: true, name: true, color: true },
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Group entries by week and project
|
|
369
|
+
const weeklyData = new Map<string, Map<string, { ms: number; project: { id: string; name: string; color: string } }>>();
|
|
370
|
+
|
|
371
|
+
for (const entry of entries) {
|
|
372
|
+
if (!entry.endTime) continue;
|
|
373
|
+
|
|
374
|
+
// Get week start (Sunday) for this entry
|
|
375
|
+
const entryDate = new Date(entry.startTime);
|
|
376
|
+
const weekStart = new Date(entryDate);
|
|
377
|
+
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
|
378
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
379
|
+
const weekKey = weekStart.toISOString().split("T")[0]!;
|
|
380
|
+
|
|
381
|
+
if (!weeklyData.has(weekKey)) {
|
|
382
|
+
weeklyData.set(weekKey, new Map());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const weekMap = weeklyData.get(weekKey)!;
|
|
386
|
+
const projectId = entry.project.id;
|
|
387
|
+
|
|
388
|
+
if (!weekMap.has(projectId)) {
|
|
389
|
+
weekMap.set(projectId, { ms: 0, project: entry.project });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const projectData = weekMap.get(projectId)!;
|
|
393
|
+
projectData.ms += new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Convert to array sorted by week
|
|
397
|
+
const result: {
|
|
398
|
+
weekStart: string;
|
|
399
|
+
weekLabel: string;
|
|
400
|
+
projects: { projectId: string; projectName: string; projectColor: string; ms: number }[];
|
|
401
|
+
totalMs: number;
|
|
402
|
+
}[] = [];
|
|
403
|
+
|
|
404
|
+
const sortedWeeks = Array.from(weeklyData.keys()).sort();
|
|
405
|
+
for (const weekKey of sortedWeeks) {
|
|
406
|
+
const weekMap = weeklyData.get(weekKey)!;
|
|
407
|
+
const weekDate = new Date(weekKey);
|
|
408
|
+
const weekLabel = `${weekDate.getMonth() + 1}/${weekDate.getDate()}`;
|
|
409
|
+
|
|
410
|
+
const projects: { projectId: string; projectName: string; projectColor: string; ms: number }[] = [];
|
|
411
|
+
let totalMs = 0;
|
|
412
|
+
|
|
413
|
+
for (const [projectId, data] of weekMap) {
|
|
414
|
+
projects.push({
|
|
415
|
+
projectId,
|
|
416
|
+
projectName: data.project.name,
|
|
417
|
+
projectColor: data.project.color,
|
|
418
|
+
ms: data.ms,
|
|
419
|
+
});
|
|
420
|
+
totalMs += data.ms;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
result.push({
|
|
424
|
+
weekStart: weekKey,
|
|
425
|
+
weekLabel,
|
|
426
|
+
projects,
|
|
427
|
+
totalMs,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return result;
|
|
432
|
+
},
|
|
346
433
|
};
|
|
347
434
|
|
|
348
435
|
// Time entry operations
|
package/src/index.tsx
CHANGED
|
@@ -9,7 +9,77 @@ import { dirname } from "path";
|
|
|
9
9
|
|
|
10
10
|
let renderer: CliRenderer | null = null;
|
|
11
11
|
|
|
12
|
+
// Show splash screen with ASCII art paca
|
|
13
|
+
function showSplash(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
const cols = process.stdout.columns || 80;
|
|
16
|
+
const rows = process.stdout.rows || 24;
|
|
17
|
+
|
|
18
|
+
// Cute ASCII paca
|
|
19
|
+
const paca = [
|
|
20
|
+
" \\\\",
|
|
21
|
+
" \\\\ ♥",
|
|
22
|
+
" (\\__/)",
|
|
23
|
+
" (o^.^) ~paca~",
|
|
24
|
+
" z(_(\")(\"))",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const logoLines = [
|
|
28
|
+
"██████╗ █████╗ ██████╗ █████╗",
|
|
29
|
+
"██╔══██╗██╔══██╗██╔════╝██╔══██╗",
|
|
30
|
+
"██████╔╝███████║██║ ███████║",
|
|
31
|
+
"██╔═══╝ ██╔══██║██║ ██╔══██║",
|
|
32
|
+
"██║ ██║ ██║╚██████╗██║ ██║",
|
|
33
|
+
"╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝",
|
|
34
|
+
];
|
|
35
|
+
const tagline = "Task Management for the Terminal";
|
|
36
|
+
|
|
37
|
+
const totalHeight = paca.length + 2 + logoLines.length + 1 + 1;
|
|
38
|
+
const startRow = Math.max(1, Math.floor((rows - totalHeight) / 2));
|
|
39
|
+
|
|
40
|
+
// Clear screen and hide cursor
|
|
41
|
+
process.stdout.write("\x1b[2J\x1b[H\x1b[?25l");
|
|
42
|
+
|
|
43
|
+
// Output paca ASCII art centered
|
|
44
|
+
for (let i = 0; i < paca.length; i++) {
|
|
45
|
+
const line = paca[i];
|
|
46
|
+
const pad = Math.max(0, Math.floor((cols - 20) / 2));
|
|
47
|
+
process.stdout.write(`\x1b[${startRow + i};${pad + 1}H\x1b[38;2;222;184;135m${line}\x1b[0m`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Position for logo
|
|
51
|
+
const logoStartRow = startRow + paca.length + 2;
|
|
52
|
+
|
|
53
|
+
// Output PACA logo centered
|
|
54
|
+
for (let i = 0; i < logoLines.length; i++) {
|
|
55
|
+
const line = logoLines[i];
|
|
56
|
+
const pad = Math.max(0, Math.floor((cols - line.length) / 2));
|
|
57
|
+
process.stdout.write(`\x1b[${logoStartRow + i};${pad + 1}H\x1b[38;2;96;165;250m${line}\x1b[0m`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Output tagline
|
|
61
|
+
const taglineRow = logoStartRow + logoLines.length + 1;
|
|
62
|
+
const taglinePad = Math.max(0, Math.floor((cols - tagline.length) / 2));
|
|
63
|
+
process.stdout.write(`\x1b[${taglineRow};${taglinePad + 1}H\x1b[38;2;100;116;139m${tagline}\x1b[0m`);
|
|
64
|
+
|
|
65
|
+
// Move cursor to bottom
|
|
66
|
+
process.stdout.write(`\x1b[${rows};1H`);
|
|
67
|
+
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
12
74
|
async function main() {
|
|
75
|
+
// Show splash with ASCII paca
|
|
76
|
+
const splashShown = showSplash();
|
|
77
|
+
|
|
78
|
+
// If splash was shown, wait a moment before starting TUI
|
|
79
|
+
if (splashShown) {
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
81
|
+
}
|
|
82
|
+
|
|
13
83
|
// Check if database exists, if not run migration
|
|
14
84
|
if (!existsSync(DB_PATH)) {
|
|
15
85
|
console.log("Initializing Paca database...");
|
package/src/stripe.ts
CHANGED
|
@@ -119,12 +119,37 @@ export interface ListInvoicesResult {
|
|
|
119
119
|
nextCursor: string | null;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
//
|
|
122
|
+
// Cache for invoice list requests (2 minute TTL)
|
|
123
|
+
const CACHE_TTL_MS = 2 * 60 * 1000;
|
|
124
|
+
const invoiceCache = new Map<string, { data: ListInvoicesResult; timestamp: number }>();
|
|
125
|
+
|
|
126
|
+
function getCacheKey(apiKey: string, cursor?: string): string {
|
|
127
|
+
// Use last 8 chars of API key + cursor for cache key
|
|
128
|
+
return `${apiKey.slice(-8)}:${cursor ?? "first"}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Clear the invoice cache (call after creating new invoices)
|
|
132
|
+
export function clearInvoiceCache(): void {
|
|
133
|
+
invoiceCache.clear();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// List invoices from Stripe with pagination and caching
|
|
123
137
|
export async function listInvoices(
|
|
124
138
|
apiKey: string,
|
|
125
139
|
limit = 25,
|
|
126
140
|
startingAfter?: string,
|
|
141
|
+
forceRefresh = false,
|
|
127
142
|
): Promise<ListInvoicesResult> {
|
|
143
|
+
const cacheKey = getCacheKey(apiKey, startingAfter);
|
|
144
|
+
|
|
145
|
+
// Check cache first (unless force refresh)
|
|
146
|
+
if (!forceRefresh) {
|
|
147
|
+
const cached = invoiceCache.get(cacheKey);
|
|
148
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
149
|
+
return cached.data;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
128
153
|
const stripe = new Stripe(apiKey);
|
|
129
154
|
|
|
130
155
|
const params: Stripe.InvoiceListParams = {
|
|
@@ -155,9 +180,14 @@ export async function listInvoices(
|
|
|
155
180
|
};
|
|
156
181
|
});
|
|
157
182
|
|
|
158
|
-
|
|
183
|
+
const result: ListInvoicesResult = {
|
|
159
184
|
invoices,
|
|
160
185
|
hasMore: response.has_more,
|
|
161
186
|
nextCursor: response.data.length > 0 ? response.data[response.data.length - 1]?.id ?? null : null,
|
|
162
187
|
};
|
|
188
|
+
|
|
189
|
+
// Store in cache
|
|
190
|
+
invoiceCache.set(cacheKey, { data: result, timestamp: Date.now() });
|
|
191
|
+
|
|
192
|
+
return result;
|
|
163
193
|
}
|
package/src/types.ts
CHANGED
|
@@ -270,6 +270,18 @@ export interface DashboardStats {
|
|
|
270
270
|
completionRate: number;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
export interface WeeklyTimeData {
|
|
274
|
+
weekStart: string;
|
|
275
|
+
weekLabel: string;
|
|
276
|
+
projects: {
|
|
277
|
+
projectId: string;
|
|
278
|
+
projectName: string;
|
|
279
|
+
projectColor: string;
|
|
280
|
+
ms: number;
|
|
281
|
+
}[];
|
|
282
|
+
totalMs: number;
|
|
283
|
+
}
|
|
284
|
+
|
|
273
285
|
export interface TimesheetGroup {
|
|
274
286
|
project: {
|
|
275
287
|
id: string;
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
const PACA_TEXT = `
|
|
2
|
-
██████╗ █████╗ ██████╗ █████╗
|
|
3
|
-
██╔══██╗██╔══██╗██╔════╝██╔══██╗
|
|
4
|
-
██████╔╝███████║██║ ███████║
|
|
5
|
-
██╔═══╝ ██╔══██║██║ ██╔══██║
|
|
6
|
-
██║ ██║ ██║╚██████╗██║ ██║
|
|
7
|
-
╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
|
|
8
|
-
`;
|
|
9
|
-
|
|
10
|
-
export function SplashScreen() {
|
|
11
|
-
return (
|
|
12
|
-
<box
|
|
13
|
-
style={{
|
|
14
|
-
width: "100%",
|
|
15
|
-
height: "100%",
|
|
16
|
-
flexDirection: "column",
|
|
17
|
-
justifyContent: "center",
|
|
18
|
-
alignItems: "center",
|
|
19
|
-
}}
|
|
20
|
-
>
|
|
21
|
-
<text>{PACA_TEXT}</text>
|
|
22
|
-
<text>Task Management for the Terminal</text>
|
|
23
|
-
</box>
|
|
24
|
-
);
|
|
25
|
-
}
|