floq 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.ja.md +89 -9
  2. package/README.md +89 -9
  3. package/dist/changelog.d.ts +13 -0
  4. package/dist/changelog.js +95 -0
  5. package/dist/cli.js +44 -1
  6. package/dist/commands/comment.d.ts +2 -0
  7. package/dist/commands/comment.js +67 -0
  8. package/dist/commands/config.d.ts +4 -0
  9. package/dist/commands/config.js +123 -1
  10. package/dist/commands/setup.d.ts +1 -0
  11. package/dist/commands/setup.js +13 -0
  12. package/dist/config.d.ts +5 -0
  13. package/dist/config.js +40 -3
  14. package/dist/db/index.js +20 -0
  15. package/dist/db/schema.d.ts +83 -0
  16. package/dist/db/schema.js +6 -0
  17. package/dist/i18n/en.d.ts +237 -0
  18. package/dist/i18n/en.js +127 -3
  19. package/dist/i18n/ja.js +127 -3
  20. package/dist/index.js +14 -4
  21. package/dist/paths.d.ts +4 -0
  22. package/dist/paths.js +63 -5
  23. package/dist/ui/App.js +280 -25
  24. package/dist/ui/ModeSelector.d.ts +7 -0
  25. package/dist/ui/ModeSelector.js +37 -0
  26. package/dist/ui/SetupWizard.d.ts +6 -0
  27. package/dist/ui/SetupWizard.js +321 -0
  28. package/dist/ui/components/HelpModal.d.ts +2 -1
  29. package/dist/ui/components/HelpModal.js +118 -4
  30. package/dist/ui/components/KanbanBoard.d.ts +6 -0
  31. package/dist/ui/components/KanbanBoard.js +508 -0
  32. package/dist/ui/components/KanbanColumn.d.ts +12 -0
  33. package/dist/ui/components/KanbanColumn.js +11 -0
  34. package/dist/ui/components/ProgressBar.d.ts +7 -0
  35. package/dist/ui/components/ProgressBar.js +13 -0
  36. package/dist/ui/components/SearchBar.d.ts +8 -0
  37. package/dist/ui/components/SearchBar.js +11 -0
  38. package/dist/ui/components/SearchResults.d.ts +9 -0
  39. package/dist/ui/components/SearchResults.js +18 -0
  40. package/dist/ui/components/TaskItem.d.ts +6 -1
  41. package/dist/ui/components/TaskItem.js +3 -2
  42. package/package.json +1 -1
@@ -0,0 +1,508 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback } from 'react';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import { eq, and, inArray } from 'drizzle-orm';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { KanbanColumn } from './KanbanColumn.js';
8
+ import { HelpModal } from './HelpModal.js';
9
+ import { FunctionKeyBar } from './FunctionKeyBar.js';
10
+ import { SearchBar } from './SearchBar.js';
11
+ import { SearchResults } from './SearchResults.js';
12
+ import { getDb, schema } from '../../db/index.js';
13
+ import { t, fmt } from '../../i18n/index.js';
14
+ import { useTheme } from '../theme/index.js';
15
+ import { isTursoEnabled, getTursoConfig } from '../../config.js';
16
+ import { VERSION } from '../../version.js';
17
+ const COLUMNS = ['todo', 'doing', 'done'];
18
+ export function KanbanBoard({ onSwitchToGtd }) {
19
+ const theme = useTheme();
20
+ const { exit } = useApp();
21
+ const [mode, setMode] = useState('normal');
22
+ const [inputValue, setInputValue] = useState('');
23
+ const [currentColumnIndex, setCurrentColumnIndex] = useState(0);
24
+ const [selectedTaskIndices, setSelectedTaskIndices] = useState({
25
+ todo: 0,
26
+ doing: 0,
27
+ done: 0,
28
+ });
29
+ const [tasks, setTasks] = useState({
30
+ todo: [],
31
+ doing: [],
32
+ done: [],
33
+ });
34
+ const [message, setMessage] = useState(null);
35
+ const [selectedTask, setSelectedTask] = useState(null);
36
+ const [taskComments, setTaskComments] = useState([]);
37
+ const [selectedCommentIndex, setSelectedCommentIndex] = useState(0);
38
+ const [projects, setProjects] = useState([]);
39
+ const [projectSelectIndex, setProjectSelectIndex] = useState(0);
40
+ // Search state
41
+ const [searchQuery, setSearchQuery] = useState('');
42
+ const [searchResults, setSearchResults] = useState([]);
43
+ const [searchResultIndex, setSearchResultIndex] = useState(0);
44
+ const i18n = t();
45
+ // Status mapping:
46
+ // TODO = inbox + someday
47
+ // Doing = next + waiting
48
+ // Done = done
49
+ const loadTasks = useCallback(async () => {
50
+ const db = getDb();
51
+ // TODO: inbox + someday (non-project tasks)
52
+ const todoTasks = await db
53
+ .select()
54
+ .from(schema.tasks)
55
+ .where(and(inArray(schema.tasks.status, ['inbox', 'someday']), eq(schema.tasks.isProject, false)));
56
+ // Doing: next + waiting (non-project tasks)
57
+ const doingTasks = await db
58
+ .select()
59
+ .from(schema.tasks)
60
+ .where(and(inArray(schema.tasks.status, ['next', 'waiting']), eq(schema.tasks.isProject, false)));
61
+ // Done: done (non-project tasks)
62
+ const doneTasks = await db
63
+ .select()
64
+ .from(schema.tasks)
65
+ .where(and(eq(schema.tasks.status, 'done'), eq(schema.tasks.isProject, false)));
66
+ // Load projects for linking
67
+ const projectTasks = await db
68
+ .select()
69
+ .from(schema.tasks)
70
+ .where(and(eq(schema.tasks.isProject, true), eq(schema.tasks.status, 'next')));
71
+ setTasks({
72
+ todo: todoTasks,
73
+ doing: doingTasks,
74
+ done: doneTasks,
75
+ });
76
+ setProjects(projectTasks);
77
+ }, []);
78
+ useEffect(() => {
79
+ loadTasks();
80
+ }, [loadTasks]);
81
+ const loadTaskComments = useCallback(async (taskId) => {
82
+ const db = getDb();
83
+ const comments = await db
84
+ .select()
85
+ .from(schema.comments)
86
+ .where(eq(schema.comments.taskId, taskId));
87
+ setTaskComments(comments);
88
+ }, []);
89
+ const addCommentToTask = useCallback(async (task, content) => {
90
+ const db = getDb();
91
+ await db.insert(schema.comments).values({
92
+ id: uuidv4(),
93
+ taskId: task.id,
94
+ content: content.trim(),
95
+ createdAt: new Date(),
96
+ });
97
+ setMessage(i18n.tui.commentAdded || 'Comment added');
98
+ await loadTaskComments(task.id);
99
+ }, [i18n.tui.commentAdded, loadTaskComments]);
100
+ const deleteComment = useCallback(async (comment) => {
101
+ const db = getDb();
102
+ await db.delete(schema.comments).where(eq(schema.comments.id, comment.id));
103
+ setMessage(i18n.tui.commentDeleted || 'Comment deleted');
104
+ if (selectedTask) {
105
+ await loadTaskComments(selectedTask.id);
106
+ }
107
+ }, [i18n.tui.commentDeleted, loadTaskComments, selectedTask]);
108
+ const linkTaskToProject = useCallback(async (task, project) => {
109
+ const db = getDb();
110
+ await db.update(schema.tasks)
111
+ .set({ parentId: project.id, updatedAt: new Date() })
112
+ .where(eq(schema.tasks.id, task.id));
113
+ setMessage(fmt(i18n.tui.linkedToProject || 'Linked "{title}" to {project}', { title: task.title, project: project.title }));
114
+ await loadTasks();
115
+ }, [i18n.tui.linkedToProject, loadTasks]);
116
+ const currentColumn = COLUMNS[currentColumnIndex];
117
+ const currentTasks = tasks[currentColumn];
118
+ const selectedTaskIndex = selectedTaskIndices[currentColumn];
119
+ // Get all tasks for search
120
+ const getAllTasks = useCallback(() => {
121
+ const allTasks = [];
122
+ for (const col of COLUMNS) {
123
+ allTasks.push(...tasks[col]);
124
+ }
125
+ return allTasks;
126
+ }, [tasks]);
127
+ // Search tasks by query
128
+ const searchTasks = useCallback((query) => {
129
+ if (!query.trim())
130
+ return [];
131
+ const lowerQuery = query.toLowerCase();
132
+ const allTasks = getAllTasks();
133
+ return allTasks.filter(task => task.title.toLowerCase().includes(lowerQuery) ||
134
+ (task.description && task.description.toLowerCase().includes(lowerQuery)));
135
+ }, [getAllTasks]);
136
+ // Handle search query change
137
+ const handleSearchChange = useCallback((value) => {
138
+ setSearchQuery(value);
139
+ const results = searchTasks(value);
140
+ setSearchResults(results);
141
+ setSearchResultIndex(0);
142
+ }, [searchTasks]);
143
+ const addTask = useCallback(async (title) => {
144
+ if (!title.trim())
145
+ return;
146
+ const db = getDb();
147
+ const now = new Date();
148
+ await db.insert(schema.tasks)
149
+ .values({
150
+ id: uuidv4(),
151
+ title: title.trim(),
152
+ status: 'inbox', // New tasks go to inbox (which maps to TODO)
153
+ createdAt: now,
154
+ updatedAt: now,
155
+ });
156
+ setMessage(fmt(i18n.tui.added, { title: title.trim() }));
157
+ await loadTasks();
158
+ }, [i18n.tui.added, loadTasks]);
159
+ const handleInputSubmit = async (value) => {
160
+ if (mode === 'add-comment' && selectedTask) {
161
+ if (value.trim()) {
162
+ await addCommentToTask(selectedTask, value);
163
+ }
164
+ setMode('task-detail');
165
+ setInputValue('');
166
+ return;
167
+ }
168
+ // Handle search mode submit
169
+ if (mode === 'search') {
170
+ if (searchResults.length > 0) {
171
+ const task = searchResults[searchResultIndex];
172
+ setSelectedTask(task);
173
+ loadTaskComments(task.id);
174
+ setMode('task-detail');
175
+ }
176
+ else {
177
+ setMode('normal');
178
+ }
179
+ setSearchQuery('');
180
+ setSearchResults([]);
181
+ setSearchResultIndex(0);
182
+ return;
183
+ }
184
+ if (value.trim()) {
185
+ await addTask(value);
186
+ }
187
+ setInputValue('');
188
+ setMode('normal');
189
+ };
190
+ const moveTaskRight = useCallback(async (task) => {
191
+ const db = getDb();
192
+ let newStatus;
193
+ // Determine new status based on current status
194
+ if (task.status === 'inbox' || task.status === 'someday') {
195
+ // TODO -> Doing (next)
196
+ newStatus = 'next';
197
+ }
198
+ else if (task.status === 'next' || task.status === 'waiting') {
199
+ // Doing -> Done
200
+ newStatus = 'done';
201
+ }
202
+ else {
203
+ // Already done, do nothing
204
+ return;
205
+ }
206
+ await db.update(schema.tasks)
207
+ .set({ status: newStatus, waitingFor: null, updatedAt: new Date() })
208
+ .where(eq(schema.tasks.id, task.id));
209
+ setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
210
+ await loadTasks();
211
+ }, [i18n.tui.movedTo, i18n.status, loadTasks]);
212
+ const moveTaskLeft = useCallback(async (task) => {
213
+ const db = getDb();
214
+ let newStatus;
215
+ // Determine new status based on current status
216
+ if (task.status === 'next' || task.status === 'waiting') {
217
+ // Doing -> TODO (inbox)
218
+ newStatus = 'inbox';
219
+ }
220
+ else if (task.status === 'done') {
221
+ // Done -> Doing (next)
222
+ newStatus = 'next';
223
+ }
224
+ else {
225
+ // Already in TODO, do nothing
226
+ return;
227
+ }
228
+ await db.update(schema.tasks)
229
+ .set({ status: newStatus, waitingFor: null, updatedAt: new Date() })
230
+ .where(eq(schema.tasks.id, task.id));
231
+ setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
232
+ await loadTasks();
233
+ }, [i18n.tui.movedTo, i18n.status, loadTasks]);
234
+ const markTaskDone = useCallback(async (task) => {
235
+ const db = getDb();
236
+ await db.update(schema.tasks)
237
+ .set({ status: 'done', updatedAt: new Date() })
238
+ .where(eq(schema.tasks.id, task.id));
239
+ setMessage(fmt(i18n.tui.completed, { title: task.title }));
240
+ await loadTasks();
241
+ }, [i18n.tui.completed, loadTasks]);
242
+ const getColumnLabel = (column) => {
243
+ return i18n.kanban[column];
244
+ };
245
+ useInput((input, key) => {
246
+ // Handle help mode - any key closes
247
+ if (mode === 'help') {
248
+ setMode('normal');
249
+ return;
250
+ }
251
+ // Handle search mode
252
+ if (mode === 'search') {
253
+ if (key.escape) {
254
+ setSearchQuery('');
255
+ setSearchResults([]);
256
+ setSearchResultIndex(0);
257
+ setMode('normal');
258
+ return;
259
+ }
260
+ // Navigate search results with Ctrl+j/k or Ctrl+n/p
261
+ if (key.ctrl && (input === 'j' || input === 'n')) {
262
+ setSearchResultIndex((prev) => prev < searchResults.length - 1 ? prev + 1 : 0);
263
+ return;
264
+ }
265
+ if (key.ctrl && (input === 'k' || input === 'p')) {
266
+ setSearchResultIndex((prev) => prev > 0 ? prev - 1 : Math.max(0, searchResults.length - 1));
267
+ return;
268
+ }
269
+ // Let TextInput handle other keys
270
+ return;
271
+ }
272
+ // Handle add mode
273
+ if (mode === 'add' || mode === 'add-comment') {
274
+ if (key.escape) {
275
+ setInputValue('');
276
+ if (mode === 'add-comment') {
277
+ setMode('task-detail');
278
+ }
279
+ else {
280
+ setMode('normal');
281
+ }
282
+ }
283
+ return;
284
+ }
285
+ // Handle task-detail mode
286
+ if (mode === 'task-detail') {
287
+ if (key.escape || key.backspace || input === 'b') {
288
+ setMode('normal');
289
+ setSelectedTask(null);
290
+ setTaskComments([]);
291
+ setSelectedCommentIndex(0);
292
+ return;
293
+ }
294
+ // Navigate comments
295
+ if (key.upArrow || input === 'k') {
296
+ setSelectedCommentIndex((prev) => (prev > 0 ? prev - 1 : Math.max(0, taskComments.length - 1)));
297
+ return;
298
+ }
299
+ if (key.downArrow || input === 'j') {
300
+ setSelectedCommentIndex((prev) => (prev < taskComments.length - 1 ? prev + 1 : 0));
301
+ return;
302
+ }
303
+ // Delete comment
304
+ if (input === 'd' && taskComments.length > 0) {
305
+ const comment = taskComments[selectedCommentIndex];
306
+ deleteComment(comment).then(() => {
307
+ if (selectedCommentIndex >= taskComments.length - 1) {
308
+ setSelectedCommentIndex(Math.max(0, selectedCommentIndex - 1));
309
+ }
310
+ });
311
+ return;
312
+ }
313
+ // Add comment
314
+ if (input === 'i') {
315
+ setMode('add-comment');
316
+ return;
317
+ }
318
+ // Link to project (P key)
319
+ if (input === 'P' && projects.length > 0) {
320
+ setProjectSelectIndex(0);
321
+ setMode('select-project');
322
+ return;
323
+ }
324
+ return;
325
+ }
326
+ // Handle select-project mode
327
+ if (mode === 'select-project') {
328
+ if (key.escape) {
329
+ setProjectSelectIndex(0);
330
+ setMode('task-detail');
331
+ return;
332
+ }
333
+ // Navigate projects
334
+ if (key.upArrow || input === 'k') {
335
+ setProjectSelectIndex((prev) => (prev > 0 ? prev - 1 : projects.length - 1));
336
+ return;
337
+ }
338
+ if (key.downArrow || input === 'j') {
339
+ setProjectSelectIndex((prev) => (prev < projects.length - 1 ? prev + 1 : 0));
340
+ return;
341
+ }
342
+ // Select project with Enter
343
+ if (key.return && selectedTask && projects.length > 0) {
344
+ const project = projects[projectSelectIndex];
345
+ linkTaskToProject(selectedTask, project);
346
+ setProjectSelectIndex(0);
347
+ setMode('task-detail');
348
+ return;
349
+ }
350
+ return;
351
+ }
352
+ // Clear message on any input
353
+ if (message) {
354
+ setMessage(null);
355
+ }
356
+ // Show help
357
+ if (input === '?') {
358
+ setMode('help');
359
+ return;
360
+ }
361
+ // Search mode
362
+ if (input === '/') {
363
+ setMode('search');
364
+ setSearchQuery('');
365
+ setSearchResults([]);
366
+ setSearchResultIndex(0);
367
+ return;
368
+ }
369
+ // Quit
370
+ if (input === 'q' || (key.ctrl && input === 'c')) {
371
+ exit();
372
+ return;
373
+ }
374
+ // Add task
375
+ if (input === 'a') {
376
+ setMode('add');
377
+ return;
378
+ }
379
+ // Direct column switch with number keys
380
+ if (input === '1') {
381
+ setCurrentColumnIndex(0);
382
+ return;
383
+ }
384
+ if (input === '2') {
385
+ setCurrentColumnIndex(1);
386
+ return;
387
+ }
388
+ if (input === '3') {
389
+ setCurrentColumnIndex(2);
390
+ return;
391
+ }
392
+ // Navigate between columns
393
+ if (key.leftArrow || input === 'h') {
394
+ setCurrentColumnIndex((prev) => (prev > 0 ? prev - 1 : COLUMNS.length - 1));
395
+ return;
396
+ }
397
+ if (key.rightArrow || input === 'l') {
398
+ setCurrentColumnIndex((prev) => (prev < COLUMNS.length - 1 ? prev + 1 : 0));
399
+ return;
400
+ }
401
+ // Navigate within column
402
+ if (key.upArrow || input === 'k') {
403
+ setSelectedTaskIndices((prev) => ({
404
+ ...prev,
405
+ [currentColumn]: prev[currentColumn] > 0 ? prev[currentColumn] - 1 : Math.max(0, currentTasks.length - 1),
406
+ }));
407
+ return;
408
+ }
409
+ if (key.downArrow || input === 'j') {
410
+ setSelectedTaskIndices((prev) => ({
411
+ ...prev,
412
+ [currentColumn]: prev[currentColumn] < currentTasks.length - 1 ? prev[currentColumn] + 1 : 0,
413
+ }));
414
+ return;
415
+ }
416
+ // Open task detail (Enter)
417
+ if (key.return && currentTasks.length > 0) {
418
+ const task = currentTasks[selectedTaskIndex];
419
+ setSelectedTask(task);
420
+ loadTaskComments(task.id);
421
+ setMode('task-detail');
422
+ return;
423
+ }
424
+ // Move task right (m)
425
+ if (input === 'm' && currentTasks.length > 0 && currentColumn !== 'done') {
426
+ const task = currentTasks[selectedTaskIndex];
427
+ moveTaskRight(task).then(() => {
428
+ // Adjust selection if needed
429
+ if (selectedTaskIndex >= currentTasks.length - 1) {
430
+ setSelectedTaskIndices((prev) => ({
431
+ ...prev,
432
+ [currentColumn]: Math.max(0, prev[currentColumn] - 1),
433
+ }));
434
+ }
435
+ });
436
+ return;
437
+ }
438
+ // Move task left (Backspace)
439
+ if ((key.backspace || key.delete) && currentTasks.length > 0 && currentColumn !== 'todo') {
440
+ const task = currentTasks[selectedTaskIndex];
441
+ moveTaskLeft(task).then(() => {
442
+ // Adjust selection if needed
443
+ if (selectedTaskIndex >= currentTasks.length - 1) {
444
+ setSelectedTaskIndices((prev) => ({
445
+ ...prev,
446
+ [currentColumn]: Math.max(0, prev[currentColumn] - 1),
447
+ }));
448
+ }
449
+ });
450
+ return;
451
+ }
452
+ // Mark as done (d)
453
+ if (input === 'd' && currentTasks.length > 0) {
454
+ const task = currentTasks[selectedTaskIndex];
455
+ markTaskDone(task).then(() => {
456
+ if (selectedTaskIndex >= currentTasks.length - 1) {
457
+ setSelectedTaskIndices((prev) => ({
458
+ ...prev,
459
+ [currentColumn]: Math.max(0, prev[currentColumn] - 1),
460
+ }));
461
+ }
462
+ });
463
+ return;
464
+ }
465
+ // Refresh
466
+ if (input === 'r') {
467
+ loadTasks();
468
+ setMessage(i18n.tui.refreshed);
469
+ return;
470
+ }
471
+ });
472
+ // Help modal overlay
473
+ if (mode === 'help') {
474
+ return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(HelpModal, { onClose: () => setMode('normal'), isKanban: true }) }));
475
+ }
476
+ const formatTitle = (title) => theme.style.headerUppercase ? title.toUpperCase() : title;
477
+ // Turso connection info
478
+ const tursoEnabled = isTursoEnabled();
479
+ const tursoHost = tursoEnabled ? (() => {
480
+ const config = getTursoConfig();
481
+ if (config) {
482
+ try {
483
+ return new URL(config.url).host;
484
+ }
485
+ catch {
486
+ return config.url;
487
+ }
488
+ }
489
+ return '';
490
+ })() : '';
491
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: formatTitle(i18n.tui.title) }), _jsx(Text, { color: theme.colors.accent, children: " [KANBAN]" }), _jsx(Text, { color: theme.colors.textMuted, children: theme.name === 'modern' ? ` v${VERSION}` : ` VER ${VERSION}` }), tursoEnabled && (_jsxs(Text, { color: theme.colors.accent, children: [theme.name === 'modern' ? ' ☁️ ' : ' [SYNC] ', tursoHost] })), !tursoEnabled && (_jsx(Text, { color: theme.colors.textMuted, children: theme.name === 'modern' ? ' 💾 local' : ' [LOCAL]' }))] }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.helpHint })] }), (mode === 'task-detail' || mode === 'add-comment' || mode === 'select-project') && selectedTask ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: theme.colors.accent, bold: true, children: [theme.name === 'modern' ? '📋 ' : '>> ', i18n.tui.taskDetailTitle || 'Task Details'] }), _jsxs(Text, { color: theme.colors.textMuted, children: [" (Esc/b: ", i18n.tui.back || 'back', ", ", i18n.tui.commentHint || 'i: add comment', ")"] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.border, paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.colors.text, bold: true, children: selectedTask.title }), selectedTask.description && (_jsx(Text, { color: theme.colors.textMuted, children: selectedTask.description })), _jsxs(Text, { color: theme.colors.textMuted, children: [i18n.status[selectedTask.status], selectedTask.waitingFor && ` - ${selectedTask.waitingFor}`, selectedTask.dueDate && ` (${selectedTask.dueDate.toLocaleDateString()})`] })] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.comments || 'Comments', " (", taskComments.length, ")"] }) }), _jsx(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.border, paddingX: 1, paddingY: 1, minHeight: 5, children: taskComments.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: i18n.tui.noComments || 'No comments yet' })) : (taskComments.map((comment, index) => {
492
+ const isSelected = index === selectedCommentIndex && mode === 'task-detail';
493
+ return (_jsxs(Box, { flexDirection: "row", marginBottom: 1, children: [_jsx(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.textMuted, children: isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.colors.textMuted, children: ["[", comment.createdAt.toLocaleString(), "]"] }), _jsx(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: comment.content })] })] }, comment.id));
494
+ })) }), mode === 'add-comment' && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.addComment || 'New comment: ' }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: "" }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })), mode === 'select-project' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.selectProject || 'Select project for', ": ", selectedTask.title] }), _jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, children: projects.map((project, index) => (_jsxs(Text, { color: index === projectSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === projectSelectIndex, children: [index === projectSelectIndex ? theme.style.selectedPrefix : theme.style.unselectedPrefix, project.title] }, project.id))) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.selectProjectHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] }))] })) : mode === 'search' ? (_jsx(_Fragment, { children: searchQuery && (_jsx(SearchResults, { results: searchResults, selectedIndex: searchResultIndex, query: searchQuery })) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: COLUMNS.map((column, index) => (_jsx(Box, { flexGrow: 1, flexBasis: 0, marginRight: index < 2 ? 1 : 0, children: _jsxs(Text, { color: currentColumnIndex === index ? theme.colors.textHighlight : theme.colors.textMuted, children: [index + 1, ":"] }) }, column))) }), _jsx(Box, { flexDirection: "row", children: COLUMNS.map((column, index) => (_jsx(KanbanColumn, { title: getColumnLabel(column), tasks: tasks[column], isActive: index === currentColumnIndex, selectedTaskIndex: selectedTaskIndices[column], columnIndex: index }, column))) })] })), mode === 'add' && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.newTask }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: i18n.tui.placeholder }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })), mode === 'search' && (_jsx(SearchBar, { value: searchQuery, onChange: handleSearchChange, onSubmit: handleInputSubmit })), message && mode === 'normal' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.textHighlight, children: message }) })), _jsx(Box, { marginTop: 1, children: mode === 'select-project' ? (_jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.selectProjectHelp || 'j/k: select, Enter: confirm, Esc: cancel' })) : (mode === 'task-detail' || mode === 'add-comment') ? (theme.style.showFunctionKeys ? (_jsx(FunctionKeyBar, { keys: [
495
+ { key: 'i', label: i18n.tui.keyBar.comment },
496
+ { key: 'd', label: i18n.tui.keyBar.delete },
497
+ { key: 'P', label: i18n.tui.keyBar.project },
498
+ { key: 'b', label: i18n.tui.keyBar.back },
499
+ ] })) : (_jsx(Text, { color: theme.colors.textMuted, children: "i=comment d=delete P=link j/k=select Esc/b=back" }))) : theme.style.showFunctionKeys ? (_jsx(FunctionKeyBar, { keys: [
500
+ { key: 'a', label: i18n.tui.keyBar.add },
501
+ { key: 'd', label: i18n.tui.keyBar.done },
502
+ { key: 'm', label: '→' },
503
+ { key: 'BS', label: '←' },
504
+ { key: 'Enter', label: 'detail' },
505
+ { key: '?', label: i18n.tui.keyBar.help },
506
+ { key: 'q', label: i18n.tui.keyBar.quit },
507
+ ] })) : (_jsx(Text, { color: theme.colors.textMuted, children: "a=add d=done m=\u2192 BS=\u2190 Enter=detail h/l=col j/k=task ?=help q=quit" })) })] }));
508
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import type { Task } from '../../db/schema.js';
3
+ export type KanbanColumnType = 'todo' | 'doing' | 'done';
4
+ interface KanbanColumnProps {
5
+ title: string;
6
+ tasks: Task[];
7
+ isActive: boolean;
8
+ selectedTaskIndex: number;
9
+ columnIndex: number;
10
+ }
11
+ export declare function KanbanColumn({ title, tasks, isActive, selectedTaskIndex, columnIndex, }: KanbanColumnProps): React.ReactElement;
12
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useTheme } from '../theme/index.js';
4
+ export function KanbanColumn({ title, tasks, isActive, selectedTaskIndex, columnIndex, }) {
5
+ const theme = useTheme();
6
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexBasis: 0, borderStyle: theme.borders.list, borderColor: isActive ? theme.colors.borderActive : theme.colors.border, marginRight: columnIndex < 2 ? 1 : 0, children: [_jsx(Box, { paddingX: 1, justifyContent: "center", borderStyle: undefined, children: _jsxs(Text, { bold: true, color: isActive ? theme.colors.primary : theme.colors.textMuted, inverse: isActive && theme.style.tabActiveInverse, children: [title, " (", tasks.length, ")"] }) }), _jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, minHeight: 8, children: tasks.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: "-" })) : (tasks.map((task, index) => {
7
+ const isSelected = isActive && index === selectedTaskIndex;
8
+ const shortId = task.id.slice(0, 6);
9
+ return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title] }) }, task.id));
10
+ })) })] }));
11
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ export interface ProgressBarProps {
3
+ completed: number;
4
+ total: number;
5
+ width?: number;
6
+ }
7
+ export declare function ProgressBar({ completed, total, width }: ProgressBarProps): React.ReactElement;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import { useTheme } from '../theme/index.js';
4
+ export function ProgressBar({ completed, total, width = 10 }) {
5
+ const theme = useTheme();
6
+ const [filledChar, emptyChar] = theme.style.loadingChars;
7
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
8
+ const filledCount = total > 0 ? Math.round((completed / total) * width) : 0;
9
+ const emptyCount = width - filledCount;
10
+ const filledBar = filledChar.repeat(filledCount);
11
+ const emptyBar = emptyChar.repeat(emptyCount);
12
+ return (_jsxs(Text, { color: theme.colors.textMuted, children: [' ', filledBar, _jsx(Text, { color: theme.colors.muted, children: emptyBar }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", percentage, "% (", completed, "/", total, ")"] })] }));
13
+ }
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface SearchBarProps {
3
+ value: string;
4
+ onChange: (value: string) => void;
5
+ onSubmit: (value: string) => void;
6
+ }
7
+ export declare function SearchBar({ value, onChange, onSubmit }: SearchBarProps): React.ReactElement;
8
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { t } from '../../i18n/index.js';
5
+ import { useTheme } from '../theme/index.js';
6
+ export function SearchBar({ value, onChange, onSubmit }) {
7
+ const i18n = t();
8
+ const theme = useTheme();
9
+ const search = i18n.tui.search;
10
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: search.prefix }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, placeholder: search.placeholder }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", search.help] })] }));
11
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import type { Task } from '../../db/schema.js';
3
+ interface SearchResultsProps {
4
+ results: Task[];
5
+ selectedIndex: number;
6
+ query: string;
7
+ }
8
+ export declare function SearchResults({ results, selectedIndex, query }: SearchResultsProps): React.ReactElement;
9
+ export {};
@@ -0,0 +1,18 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { t } from '../../i18n/index.js';
4
+ import { useTheme } from '../theme/index.js';
5
+ export function SearchResults({ results, selectedIndex, query }) {
6
+ const i18n = t();
7
+ const theme = useTheme();
8
+ const search = i18n.tui.search;
9
+ if (!query) {
10
+ return _jsx(_Fragment, {});
11
+ }
12
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, paddingY: 1, minHeight: 5, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.colors.secondary, bold: true, children: ["[", search.resultsTitle, "] (", results.length, ")"] }) }), results.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: search.noResults })) : (results.slice(0, 10).map((task, index) => {
13
+ const isSelected = index === selectedIndex;
14
+ const shortId = task.id.slice(0, 8);
15
+ const statusLabel = i18n.status[task.status];
16
+ return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, _jsxs(Text, { color: theme.colors.textMuted, children: [" (", statusLabel, ")"] }), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" - ", task.waitingFor] }))] }) }, task.id));
17
+ })), results.length > 10 && (_jsxs(Text, { color: theme.colors.textMuted, italic: true, children: ["... and ", results.length - 10, " more"] }))] }));
18
+ }
@@ -1,9 +1,14 @@
1
1
  import React from 'react';
2
2
  import type { Task } from '../../db/schema.js';
3
+ export interface ProjectProgress {
4
+ completed: number;
5
+ total: number;
6
+ }
3
7
  interface TaskItemProps {
4
8
  task: Task;
5
9
  isSelected: boolean;
6
10
  projectName?: string;
11
+ progress?: ProjectProgress;
7
12
  }
8
- export declare function TaskItem({ task, isSelected, projectName }: TaskItemProps): React.ReactElement;
13
+ export declare function TaskItem({ task, isSelected, projectName, progress }: TaskItemProps): React.ReactElement;
9
14
  export {};
@@ -2,9 +2,10 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { t } from '../../i18n/index.js';
4
4
  import { useTheme } from '../theme/index.js';
5
- export function TaskItem({ task, isSelected, projectName }) {
5
+ import { ProgressBar } from './ProgressBar.js';
6
+ export function TaskItem({ task, isSelected, projectName, progress }) {
6
7
  const shortId = task.id.slice(0, 8);
7
8
  const i18n = t();
8
9
  const theme = useTheme();
9
- return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, projectName && (_jsxs(Text, { color: theme.colors.statusSomeday, children: [" @", projectName] })), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" (", i18n.status.waiting.toLowerCase(), ": ", task.waitingFor, ")"] })), task.dueDate && (_jsxs(Text, { color: theme.colors.accent, children: [" (due: ", task.dueDate.toLocaleDateString(), ")"] }))] }) }));
10
+ return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, projectName && (_jsxs(Text, { color: theme.colors.statusSomeday, children: [" @", projectName] })), progress && progress.total > 0 && (_jsx(ProgressBar, { completed: progress.completed, total: progress.total })), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" (", i18n.status.waiting.toLowerCase(), ": ", task.waitingFor, ")"] })), task.dueDate && (_jsxs(Text, { color: theme.colors.accent, children: [" (due: ", task.dueDate.toLocaleDateString(), ")"] }))] }) }));
10
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "floq",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Floq - Getting Things Done Task Manager with MS-DOS style themes",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",