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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # Paca
2
2
 
3
- A simple tui app for task, timer and invoicing for projects.
3
+ ![Paca](assets/paca-mascot.png "Paca - the friendly alpaca mascot")
4
+
5
+ A simple TUI app for task, timer and invoicing for projects.
4
6
 
5
7
  ![License](https://img.shields.io/npm/l/pacatui)
6
8
  ![npm](https://img.shields.io/npm/v/pacatui)
@@ -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
- # Install bun if you haven't already
28
- curl -fsSL https://bun.sh/install | bash
27
+ npm install -g pacatui
28
+ paca
29
+ ```
29
30
 
30
- # Install pacatui globally
31
- bun install -g pacatui
31
+ ### Via bun
32
32
 
33
- # Run it
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.0",
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
- setInvoicesLoading(true);
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
- setSelectedInvoiceIndex(0);
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
- loadDashboard();
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
- loadDashboard();
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
- loadTasks();
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
- loadTasks();
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 browser
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, [url], { detached: true, stdio: "ignore" });
888
- showMessage(invoice.hostedUrl ? "Opening invoice..." : "Opening in Stripe dashboard...");
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: 1,
128
- gap: 1,
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="Task Status"
348
+ title="Weekly Time"
134
349
  style={{
135
- border: true,
136
- borderColor: "#334155",
137
- padding: 1,
138
- flexDirection: "column",
139
- width: "100%",
350
+ justifyContent: "center",
351
+ alignItems: "center",
140
352
  }}
141
353
  >
142
- <StackedBarChart stats={stats} width={termWidth} />
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: "short",
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
- number: 12,
51
- date: 14,
52
- customer: 24,
50
+ invoiceId: 18,
53
51
  status: 10,
54
- amount: 12,
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(72)}</text>
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
- <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
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
- <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
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
- <text fg={COLORS.borderOff}>{"─".repeat(72)}</text>
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.number }}>
224
- <text fg="#64748b">Invoice #</text>
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 Number */}
259
- <box style={{ width: COL.number }}>
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" && projects[selectedIndex]) {
26
- onSelect(projects[selectedIndex].id);
36
+ if (key.name === "return" && filteredProjects[selectedIndex]) {
37
+ onSelect(filteredProjects[selectedIndex].id);
27
38
  return;
28
39
  }
29
- if (key.name === "j" || key.name === "down") {
30
- setSelectedIndex((i) => Math.min(i + 1, projects.length - 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 === "k" || key.name === "up") {
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 style={{ marginTop: 1, flexGrow: 1 }}>
42
- <scrollbox focused style={{ flexGrow: 1 }}>
43
- {projects.map((project, index) => (
44
- <box
45
- key={project.id}
46
- style={{
47
- paddingLeft: 1,
48
- paddingRight: 1,
49
- backgroundColor:
50
- index === selectedIndex ? "#1e40af" : "transparent",
51
- }}
52
- >
53
- <text>
54
- <span fg={project.color}>[*] </span>
55
- <span
56
- fg={index === selectedIndex ? "#ffffff" : "#e2e8f0"}
57
- attributes={index === selectedIndex ? "bold" : undefined}
58
- >
59
- {project.name}
60
- </span>
61
- {project.hourlyRate != null && (
62
- <span fg="#10b981"> ${project.hourlyRate}/hr</span>
63
- )}
64
- </text>
65
- </box>
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
- <text fg={COLORS.borderOff}>{"─".repeat(56)}</text>
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;
@@ -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
- // List invoices from Stripe with pagination
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
- return {
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
- }