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